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, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181 AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182 DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183 CommandEnvelope, CommandId,
184 validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185 HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186 HostDecisionAuthority as ProviderHostDecisionAuthority,
187 HostRequestDecision as ProviderHostRequestDecision,
188 InputAction,
189 PromptFraming, PromptPayload, ResumeLaunchRequest,
190 ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191 ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192 ProviderSnapshot, ProviderStopReason,
193 ResumeTarget,
194 SessionGeneration, StartRequest,
195 TerminalControl, TerminalText,
196 SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197 HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215pub fn spawn_model() -> Option<String> {
217 SPAWN_SESSION_OPTIONS.try_with(|o| o.as_ref().map(|o| o.model.clone())).ok().flatten().filter(|m| !m.is_empty())
218 .or_else(|| SPAWN_NICK_MODEL.try_with(|m| m.clone()).ok().flatten().filter(|m| !m.is_empty()))
219}
220
221tokio::task_local! {
222 static SPAWN_NICK_MODEL: Option<String>;
225 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
228 static RESUME_PROVIDER_SESSION: Option<String>;
230 static SPAWN_ORIGIN: Option<String>;
232 static SPAWN_PARENT: Option<String>;
233}
234
235fn spawn_selection(
237 options: &gate4agent_node_protocol::SpawnSessionOptions,
238) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
239 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
240 match (&options.model, &options.effort) {
241 (None, None) => Ok(None),
242 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
243 (Some(model), effort) => {
244 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
245 if let Some(e) = effort {
246 sel = sel.with_value("effort", e.clone());
247 }
248 Ok(Some(sel))
249 }
250 }
251}
252
253mod browser_bridge;
254
255use gate4agent_node_core::drive_loop_profile;
256
257#[path = "bundle_delivery.rs"]
258mod bundle_delivery;
259
260#[path = "context_pack_store.rs"]
261mod context_pack_store;
262
263#[cfg(windows)]
264pub use crate::platform::DEFAULT_NODE_ENDPOINT;
265
266const NODE_EVENT_HISTORY_MAX: usize = 4_096;
267const NODE_BROADCAST_CAPACITY: usize = 1_024;
268const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
269const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
275const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
282const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
283const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
284const MAX_PREAUTH_CONNECTIONS: usize = 32;
285const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
286const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
287const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
288const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
289const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
290const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
296const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
304const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
312const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
319const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
324const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
325const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
326const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
327const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
328const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
329const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
330const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
331const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
332const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
333const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
334const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
335const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
336const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
339const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
346const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
349const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
350const GIT_STATUS_MAX_ENTRIES: usize = 128;
351const GIT_COMMIT_MAX_ENTRIES: usize = 12;
352const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
353const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
354const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
355const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
356const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
357const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
358const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
359const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
360const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
361const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
362 "provider-session-identity-allocation-failed";
363const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
364const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
365const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
366const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
367const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
368const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
369const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
370 "durable-state-path-semantics-unsupported";
371
372fn opaque_windows_path(value: String) -> OpaqueHostPath {
373 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
374}
375
376fn agent_progress_from_provider_snapshot(
377 address: SessionAddress,
378 provider: &ProviderSnapshot,
379) -> Option<SessionAgentProgress> {
380 let mut truncated = false;
381 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
382 let mut active_tool_labels = Vec::with_capacity(
383 provider
384 .active_tools
385 .len()
386 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
387 );
388 for tool in &provider.active_tools {
389 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
390 truncated = true;
391 break;
392 }
393 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
394 active_tool_labels.push(label);
395 }
396 }
397 let attention = provider
398 .interactions
399 .iter()
400 .find(|interaction| {
401 matches!(
402 interaction.status,
403 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
404 )
405 })
406 .map(|interaction| AgentProgressAttentionV1 {
407 kind: match interaction.interaction_kind {
408 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
409 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
410 },
411 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
412 });
413 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
414 let usage = (provider.completed_turns > 0
415 || provider.usage.input_tokens > 0
416 || provider.usage.output_tokens > 0
417 || provider.usage.cache_read_tokens > 0
418 || provider.usage.cache_write_tokens > 0
419 || provider.usage.reasoning_tokens > 0)
420 .then_some(AgentProgressUsageV1 {
421 input_tokens: provider.usage.input_tokens,
422 output_tokens: provider.usage.output_tokens,
423 cache_read_tokens: provider.usage.cache_read_tokens,
424 cache_write_tokens: provider.usage.cache_write_tokens,
425 reasoning_tokens: provider.usage.reasoning_tokens,
426 });
427 let progress = AgentProgressV1 {
428 provider_sequence: provider.sequence,
429 activity: provider.activity,
430 completed_turns: provider.completed_turns,
431 usage,
432 current: AgentProgressCurrentV1::from(provider.activity),
433 active_tool_labels,
434 active_tool_count,
435 attention,
436 subagent_count,
437 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
438 gap_count: provider.gap_count,
439 stale: provider.stale,
440 truncated,
441 };
442 let entry = SessionAgentProgress { address, progress };
443 serde_json::to_vec(&entry)
444 .ok()
445 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
446 .map(|_| entry)
447}
448
449fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
450 match u32::try_from(count) {
451 Ok(count) => count,
452 Err(_) => {
453 *truncated = true;
454 u32::MAX
455 }
456 }
457}
458
459fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
460 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
461 return None;
462 }
463 Some(match event {
464 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
465 ProviderEvent::SessionIdentityObserved { .. } => {
466 AgentProgressEventKindV1::SessionIdentityObserved
467 }
468 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
469 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
470 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
471 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
472 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
473 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
474 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
475 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
476 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
477 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
478 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
479 ProviderEvent::InteractionRequested { .. } => {
480 AgentProgressEventKindV1::InteractionRequested
481 }
482 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
483 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
484 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
485 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
486 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
487 ProviderEvent::UnrecognizedNotification { .. } => {
488 AgentProgressEventKindV1::UnrecognizedNotification
489 }
490 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
491 ProviderEvent::UserMessage { .. }
502 | ProviderEvent::Plan { .. }
503 | ProviderEvent::AvailableCommandsUpdated { .. }
504 | ProviderEvent::ModeChanged { .. }
505 | ProviderEvent::SessionInfoUpdated { .. }
506 | ProviderEvent::UsageUpdated { .. }
507 | ProviderEvent::ConfigOptionsUpdated { .. } => {
508 AgentProgressEventKindV1::UnrecognizedNotification
509 }
510 })
511}
512
513fn provider_family_is_live(family: AdapterFamily) -> bool {
526 match family {
527 AdapterFamily::PtySemantic
528 | AdapterFamily::Hook
529 | AdapterFamily::ManagedHook
530 | AdapterFamily::Pipe
531 | AdapterFamily::OneShot
532 | AdapterFamily::Acp => true,
533 AdapterFamily::History
534 | AdapterFamily::Resume
535 | AdapterFamily::SessionOptions
536 | AdapterFamily::CapabilityProbe => false,
537 }
538}
539
540fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
541 match by {
542 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
543 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
544 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
545 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
546 }
547}
548
549fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
561 match authority {
562 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
563 BlockAuthorityV1::HostPolicy => "denied by host policy",
564 BlockAuthorityV1::HostDeadline => {
565 "denied by host policy after the operator deadline expired"
566 }
567 BlockAuthorityV1::Operator => "declined by the operator",
568 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
569 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
570 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
571 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
572 BlockAuthorityV1::UserRejected => "rejected by the user",
573 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
574 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
575 }
576}
577
578fn host_request_denied_block(
581 method: &str,
582 by: ProviderHostDecisionAuthority,
583 reason: Option<&str>,
584) -> (String, BlockAuthorityV1, String) {
585 let authority = block_authority(by);
586 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
587 let (reason_text, _) =
591 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
592 (tool_class_label(method), authority, reason_text)
593}
594
595fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
614 match kind? {
615 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
616 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
617 _ => None,
618 }
619}
620
621fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
633 let trimmed = text.trim();
634 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
635 return (trimmed.to_owned(), None);
636 };
637 let tail = after_reason.trim_start();
638 let Some((reason, help)) = tail.split_once(". ") else {
639 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
643 return (reason.to_owned(), None);
644 };
645 let help = help.trim();
646 if help.is_empty() {
647 (reason.trim().to_owned(), None)
648 } else {
649 (reason.trim().to_owned(), Some(help.to_owned()))
650 }
651}
652
653fn tool_class_from_denial_help(help: Option<&str>) -> String {
663 help.and_then(|text| text.split("add a ").nth(1))
664 .and_then(|tail| tail.split(' ').next())
665 .filter(|word| !word.is_empty())
666 .map(str::to_owned)
667 .unwrap_or_else(|| "Tool".to_owned())
668}
669
670fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
678 fn names_quota(s: &str) -> bool {
679 let lower = s.to_ascii_lowercase();
680 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
681 }
682 vendor_code.is_some_and(names_quota) || names_quota(message)
683}
684
685fn provider_session_end_block(
701 stop_reason: &ProviderStopReason,
702) -> Option<(BlockAuthorityV1, String, String)> {
703 match stop_reason {
704 ProviderStopReason::Refusal => Some((
705 BlockAuthorityV1::ProviderRefusal,
706 "refusal".to_owned(),
707 "the provider ended the turn with stopReason: refusal".to_owned(),
708 )),
709 ProviderStopReason::ProviderError { message, vendor_code, .. }
710 if provider_error_is_quota(vendor_code.as_deref(), message) =>
711 {
712 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
713 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
714 }
715 _ => None,
716 }
717}
718
719fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
760 let ControlEventKind::ProviderEvent {
761 sequence: provider_sequence,
762 source,
763 source_sequence,
764 event: provider_event,
765 } = &event.event
766 else {
767 return None;
768 };
769 if !provider_family_is_live(source.family) {
770 return None;
771 }
772 let kind = match provider_event {
773 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
774 text: text.clone(),
775 is_delta: *is_delta,
776 },
777 ProviderEvent::Thinking { text } => {
778 AgentStreamChunkKindV1::Thinking { text: text.clone() }
779 }
780 ProviderEvent::InteractionRequested {
781 interaction_kind,
782 tool_name,
783 title,
784 prompt,
785 options,
786 ..
787 } => AgentStreamChunkKindV1::InteractionPrompt {
788 correlation_id: interaction_correlation(
789 event.instance_id,
790 event.generation,
791 *provider_sequence,
792 ),
793 interaction_kind: *interaction_kind,
794 tool_name: tool_name.clone(),
795 title: title.clone(),
804 prompt: prompt.clone(),
805 options: options
806 .iter()
807 .map(|option| AgentStreamInteractionOptionV1 {
808 option_id: option.option_id.clone(),
809 name: option.name.clone(),
810 kind: option.kind.clone(),
811 })
812 .collect(),
813 },
814 ProviderEvent::ConfigOptionsUpdated { options } => {
815 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
816 }
817 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
828 current: Some(mode_id.clone()),
829 available: available
830 .iter()
831 .map(|mode| AgentStreamNamedIdV1 {
832 id: mode.id.clone(),
833 name: mode.name.clone(),
834 description: mode.description.clone(),
835 })
836 .collect(),
837 },
838 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
842 let ProviderHostRequestDecision::Denied { by } = decision else {
843 return None;
844 };
845 let (tool_class, authority, reason_text) =
846 host_request_denied_block(method, *by, reason.as_deref());
847 AgentStreamChunkKindV1::Blocked {
848 correlation_id: None,
849 tool_class,
850 authority,
851 reason_kind: None,
852 reason: reason_text,
853 help: None,
854 }
855 }
856 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
860 let (authority, reason_kind) =
861 provider_tool_block_authority(non_execution_kind.as_deref())?;
862 let (reason, help) = split_provider_denial_text(output);
863 let (reason, _) =
864 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
865 let help = help.map(|help| {
866 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
867 });
868 AgentStreamChunkKindV1::Blocked {
869 correlation_id: Some(tool_correlation(event, source, id)),
870 tool_class: tool_class_from_denial_help(help.as_deref()),
871 authority,
872 reason_kind: Some(reason_kind.to_owned()),
873 reason,
874 help,
875 }
876 }
877 ProviderEvent::SessionEnded { stop_reason, .. } => {
881 let (authority, reason_kind, reason) =
882 stop_reason.as_ref().and_then(provider_session_end_block)?;
883 let (reason, _) =
884 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
885 AgentStreamChunkKindV1::Blocked {
886 correlation_id: None,
887 tool_class: "Session".to_owned(),
888 authority,
889 reason_kind: Some(reason_kind),
890 reason,
891 help: None,
892 }
893 }
894 ProviderEvent::SessionStarted { .. }
895 | ProviderEvent::SessionIdentityObserved { .. }
896 | ProviderEvent::TurnStarted { .. }
897 | ProviderEvent::WorkingObserved
898 | ProviderEvent::ToolStarted { .. }
899 | ProviderEvent::TurnCompleted { .. }
900 | ProviderEvent::ContextWindowUsage { .. }
901 | ProviderEvent::TurnInterrupted
902 | ProviderEvent::Error { .. }
903 | ProviderEvent::Ready
904 | ProviderEvent::InteractionResolved { .. }
905 | ProviderEvent::SubagentStarted { .. }
906 | ProviderEvent::SubagentStopped { .. }
907 | ProviderEvent::RateLimited { .. }
908 | ProviderEvent::UnrecognizedNotification { .. }
909 | ProviderEvent::UserMessage { .. }
910 | ProviderEvent::Plan { .. }
911 | ProviderEvent::AvailableCommandsUpdated { .. }
912 | ProviderEvent::SessionInfoUpdated { .. }
913 | ProviderEvent::UsageUpdated { .. } => return None,
914 };
915 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
916}
917
918async fn run_native_session_catalog_operation<T, E, F>(
919 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
920 operation: &'static str,
921 execute: F,
922) -> Result<T, NodeFailure>
923where
924 T: Send + 'static,
925 E: std::fmt::Display + Send + 'static,
926 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
927{
928 let task = tokio::task::spawn_blocking(move || {
929 let mut catalog = match catalog.try_lock() {
930 Ok(catalog) => catalog,
931 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
932 Err(std::sync::TryLockError::WouldBlock) => {
933 return Err(failure(
934 NodeFailureCode::BackendBusy,
935 "native session catalog operation is already running",
936 ));
937 }
938 };
939 execute(&mut catalog).map_err(|error| {
940 failure(
941 NodeFailureCode::BackendOperationFailed,
942 &format!("{operation} failed: {error}"),
943 )
944 })
945 });
946 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
947 .await
948 .map_err(|_| {
949 failure(
950 NodeFailureCode::BackendBusy,
951 &format!("{operation} exceeded its bounded deadline"),
952 )
953 })?
954 .map_err(|_| {
955 failure(
956 NodeFailureCode::BackendOperationFailed,
957 &format!("{operation} task failed"),
958 )
959 })?
960}
961
962fn windows_path_text(path: &OpaqueHostPath) -> &str {
963 path.as_utf8()
964 .expect("Windows node only creates UTF-8 opaque host paths")
965}
966
967fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
968 path.as_utf8()
969 .map(str::to_owned)
970 .ok_or_else(|| failure(
971 NodeFailureCode::InvalidRequest,
972 "this node requires UTF-8 host paths",
973 ))
974}
975
976fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
977 GitWorktreeSnapshot {
978 path: opaque_windows_path(worktree.path),
979 head: worktree.head,
980 branch: worktree.branch,
981 is_bare: worktree.is_bare,
982 is_main: worktree.is_main,
983 locked: worktree.locked,
984 lock_reason: worktree.lock_reason,
985 prunable: worktree.prunable,
986 prunable_reason: worktree.prunable_reason,
987 workspace_id: worktree.workspace_id.take(),
988 }
989}
990
991#[derive(Clone, Debug, Eq, PartialEq)]
992pub struct WorkspaceConfig {
993 workspace_id: WorkspaceId,
994 canonical_root: String,
995 worktree_service_mode: WorktreeServiceMode,
996 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
997}
998
999impl WorkspaceConfig {
1000 pub fn new(
1001 workspace_id: WorkspaceId,
1002 root: impl AsRef<Path>,
1003 ) -> Result<Self, NodeServerError> {
1004 let root = root.as_ref();
1005 if !root.is_absolute() {
1006 return Err(NodeServerError::InvalidWorkspaceRoot {
1007 workspace_id,
1008 path: root.to_string_lossy().into_owned(),
1009 message: "path is not absolute".to_owned(),
1010 });
1011 }
1012 let canonical = std::fs::canonicalize(root).map_err(|error| {
1013 NodeServerError::InvalidWorkspaceRoot {
1014 workspace_id: workspace_id.clone(),
1015 path: root.to_string_lossy().into_owned(),
1016 message: error.to_string(),
1017 }
1018 })?;
1019 if !canonical.is_dir() {
1020 return Err(NodeServerError::InvalidWorkspaceRoot {
1021 workspace_id,
1022 path: root.to_string_lossy().into_owned(),
1023 message: "path is not a directory".to_owned(),
1024 });
1025 }
1026 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1027 NodeServerError::InvalidWorkspaceRoot {
1028 workspace_id: workspace_id.clone(),
1029 path: path.to_string_lossy().into_owned(),
1030 message: "canonical path is not valid Unicode".to_owned(),
1031 }
1032 })?;
1033 let canonical_root = platform::normalize_canonical_root(canonical_root);
1034 validate_workspace_root(&workspace_id, &canonical_root)?;
1035 Ok(Self {
1036 workspace_id,
1037 canonical_root,
1038 worktree_service_mode: WorktreeServiceMode::Manual,
1039 managed_worktree_profiles: BTreeMap::new(),
1040 })
1041 }
1042
1043 pub fn workspace_id(&self) -> &WorkspaceId {
1044 &self.workspace_id
1045 }
1046
1047 pub fn canonical_root(&self) -> &str {
1048 &self.canonical_root
1049 }
1050
1051 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1052 self.worktree_service_mode = mode;
1053 self
1054 }
1055
1056 pub fn with_managed_worktree_profile(
1057 mut self,
1058 profile: ManagedWorktreeProfile,
1059 ) -> Result<Self, NodeServerError> {
1060 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1061 NodeServerError::InvalidManagedWorktreeProfile {
1062 workspace_id: self.workspace_id.clone(),
1063 profile_id: profile.profile_id().clone(),
1064 message,
1065 }
1066 })?;
1067 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1068 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1069 workspace_id: self.workspace_id,
1070 profile_id: profile.profile_id().clone(),
1071 });
1072 }
1073 if self.managed_worktree_profiles.len()
1074 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1075 {
1076 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1077 workspace_id: self.workspace_id,
1078 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1079 });
1080 }
1081 self.managed_worktree_profiles
1082 .insert(profile.profile_id().clone(), profile);
1083 Ok(self)
1084 }
1085}
1086
1087#[derive(Clone)]
1088pub struct NodeServerConfig {
1089 pub endpoint: String,
1090 api_listen: Option<std::net::SocketAddr>,
1091 bridge_listen: Option<std::net::SocketAddr>,
1094 bridge_token: Option<String>,
1097 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1100 call_home: Option<std::net::SocketAddr>,
1103 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1107 pub node_id: NodeId,
1108 pub workspaces: Vec<WorkspaceConfig>,
1109 access_token: String,
1110 pub runtime: NativeRuntimeConfig,
1111 session_record_retention: SessionRecordRetentionConfig,
1112 state_path: Option<PathBuf>,
1113 spawn_profiles: SpawnProfileRegistry,
1114 session_environment: Option<NodeSessionEnvironmentConfig>,
1115 history: Option<NativeHistoryConfig>,
1116 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1117 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1121 #[cfg(feature = "fixture")]
1122 fixture_raw_pty_runtime: bool,
1123}
1124
1125#[derive(Clone)]
1126struct NodeSessionEnvironmentConfig {
1127 root: PathBuf,
1128 resolver: Arc<dyn NodeSecretResolver>,
1129}
1130
1131impl NodeServerConfig {
1132 pub fn new(
1133 endpoint: impl Into<String>,
1134 access_token: impl Into<String>,
1135 node_id: NodeId,
1136 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1137 ) -> Result<Self, NodeServerError> {
1138 let endpoint = endpoint.into();
1139 let access_token = access_token.into();
1140 if !platform::validate_endpoint(&endpoint) {
1141 return Err(NodeServerError::InvalidEndpoint);
1142 }
1143 if access_token.is_empty() || access_token.len() > 4_096 {
1144 return Err(NodeServerError::InvalidAccessToken);
1145 }
1146 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1147 if workspaces.is_empty() {
1148 return Err(NodeServerError::NoWorkspaces);
1149 }
1150 let mut workspace_ids = BTreeMap::new();
1151 let mut workspace_roots = BTreeMap::new();
1152 for workspace in &workspaces {
1153 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1154 && workspace.managed_worktree_profiles.is_empty()
1155 {
1156 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1157 workspace.workspace_id.clone(),
1158 ));
1159 }
1160 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1161 && !workspace.managed_worktree_profiles.is_empty()
1162 {
1163 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1164 workspace.workspace_id.clone(),
1165 ));
1166 }
1167 if workspace_ids
1168 .insert(workspace.workspace_id.clone(), ())
1169 .is_some()
1170 {
1171 return Err(NodeServerError::DuplicateWorkspaceId(
1172 workspace.workspace_id.clone(),
1173 ));
1174 }
1175 let root_key = platform::root_identity(&workspace.canonical_root);
1176 if let Some(existing) = workspace_roots.insert(
1177 root_key,
1178 workspace.workspace_id.clone(),
1179 ) {
1180 return Err(NodeServerError::DuplicateWorkspaceRoot {
1181 first: existing,
1182 second: workspace.workspace_id.clone(),
1183 root: workspace.canonical_root.clone(),
1184 });
1185 }
1186 }
1187 Ok(Self {
1188 endpoint,
1189 api_listen: None,
1190 bridge_listen: None,
1191 bridge_token: None,
1192 bridge_underlay: None,
1193 call_home: None,
1194 call_home_identity: None,
1195 node_id,
1196 workspaces,
1197 access_token,
1198 runtime: NativeRuntimeConfig {
1201 worker_poll_interval_ms: 4,
1202 ..NativeRuntimeConfig::default()
1203 },
1204 session_record_retention: SessionRecordRetentionConfig::default(),
1205 state_path: None,
1206 spawn_profiles: SpawnProfileRegistry::default(),
1207 session_environment: None,
1208 history: None,
1209 harness_mcp_helper: None,
1210 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1211 #[cfg(feature = "fixture")]
1212 fixture_raw_pty_runtime: false,
1213 })
1214 }
1215
1216 pub fn with_api_listen(
1217 mut self,
1218 api_listen: std::net::SocketAddr,
1219 ) -> Result<Self, NodeServerError> {
1220 if !api_listen.ip().is_loopback() {
1221 return Err(NodeServerError::InvalidApiListen(api_listen));
1222 }
1223 self.api_listen = Some(api_listen);
1224 Ok(self)
1225 }
1226
1227 pub fn with_bridge_listen(
1231 mut self,
1232 bridge_listen: std::net::SocketAddr,
1233 ) -> Result<Self, NodeServerError> {
1234 if !bridge_listen.ip().is_loopback() {
1235 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1236 }
1237 self.bridge_listen = Some(bridge_listen);
1238 Ok(self)
1239 }
1240
1241 pub fn with_bridge_token(
1244 mut self,
1245 bridge_token: impl Into<String>,
1246 ) -> Result<Self, NodeServerError> {
1247 let bridge_token = bridge_token.into();
1248 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1249 return Err(NodeServerError::InvalidBridgeToken);
1250 }
1251 self.bridge_token = Some(bridge_token);
1252 Ok(self)
1253 }
1254
1255 pub fn with_bridge_underlay(
1260 mut self,
1261 bind: std::net::SocketAddr,
1262 transport_key: [u8; 32],
1263 underlay_token: impl Into<String>,
1264 ) -> Result<Self, NodeServerError> {
1265 if self.bridge_listen.is_none() {
1266 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1267 }
1268 if self.bridge_token.is_none() {
1269 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1270 }
1271 let underlay_token = underlay_token.into();
1272 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1273 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1274 }
1275 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1277 bind,
1278 transport_key,
1279 underlay_token,
1280 });
1281 Ok(self)
1282 }
1283
1284 pub fn with_call_home(
1305 mut self,
1306 relay: std::net::SocketAddr,
1307 ) -> Result<Self, NodeServerError> {
1308 if relay.port() == 0 || relay.ip().is_unspecified() {
1311 return Err(NodeServerError::InvalidCallHome(relay));
1312 }
1313 self.call_home = Some(relay);
1314 Ok(self)
1315 }
1316
1317 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1319 self.call_home_identity = Some(identity);
1320 self
1321 }
1322
1323 pub fn with_state_path(
1324 mut self,
1325 state_path: impl Into<PathBuf>,
1326 ) -> Result<Self, NodeServerError> {
1327 let state_path = state_path.into();
1328 if !state_path.is_absolute() || state_path.file_name().is_none() {
1329 return Err(NodeServerError::InvalidStatePath(
1330 state_path.to_string_lossy().into_owned(),
1331 ));
1332 }
1333 self.state_path = Some(state_path);
1334 Ok(self)
1335 }
1336
1337 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1338 self.spawn_profiles = spawn_profiles;
1339 self
1340 }
1341
1342 pub fn with_session_record_retention(
1348 mut self,
1349 retention: SessionRecordRetentionConfig,
1350 ) -> Self {
1351 self.session_record_retention = retention;
1352 self
1353 }
1354
1355 pub fn with_session_environment_materialization(
1356 mut self,
1357 root: impl Into<PathBuf>,
1358 resolver: Arc<dyn NodeSecretResolver>,
1359 ) -> Result<Self, NodeServerError> {
1360 let root = root.into();
1361 if !root.is_absolute() {
1362 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1363 }
1364 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1365 Ok(self)
1366 }
1367
1368 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1371 self.history = Some(history);
1372 self
1373 }
1374
1375 pub fn with_harness_mcp_helper(
1381 mut self,
1382 helper_program: impl Into<PathBuf>,
1383 ) -> Result<Self, NodeServerError> {
1384 self.harness_mcp_helper = Some(
1385 ReviewedHarnessMcpProgram::review(helper_program.into())
1386 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1387 );
1388 Ok(self)
1389 }
1390
1391 pub fn with_network_allowlist_catalog(
1396 mut self,
1397 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1398 ) -> Result<Self, NodeServerError> {
1399 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1400 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1401 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1402 });
1403 }
1404 self.network_allowlist_catalog = catalog;
1405 Ok(self)
1406 }
1407
1408 pub fn with_network_allowlist_catalog_file(
1412 self,
1413 path: impl AsRef<std::path::Path>,
1414 ) -> Result<Self, NodeServerError> {
1415 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1416 .map_err(NodeServerError::from)?;
1417 self.with_network_allowlist_catalog(catalog)
1418 }
1419}
1420
1421pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1422 platform::default_state_path(node_id.as_str())
1423 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1424}
1425
1426pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1427 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1428}
1429
1430fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1431 let parent = state_path.parent()?;
1432 let mut name = state_path.file_name()?.to_os_string();
1433 name.push(".delivery-store");
1434 Some(parent.join(name))
1435}
1436
1437fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1438 let parent = state_path.parent()?;
1439 let mut name = state_path.file_name()?.to_os_string();
1440 name.push(".context-pack-store");
1441 Some(parent.join(name))
1442}
1443
1444pub struct NodeServer {
1445 config: NodeServerConfig,
1446 runtime: NativeRuntime,
1447 shared: Arc<NodeShared>,
1448 events: EventSubscription,
1449 state_path_lock: Option<session_registry::StatePathLock>,
1450}
1451
1452#[cfg(feature = "fixture")]
1453#[derive(Clone, Default)]
1454pub struct SpawnManagedWorktreeV2FailureProbe {
1455 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1456}
1457
1458#[cfg(feature = "fixture")]
1459impl SpawnManagedWorktreeV2FailureProbe {
1460 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1461 *self
1462 .latest_code
1463 .lock()
1464 .unwrap_or_else(|poisoned| poisoned.into_inner())
1465 }
1466
1467 fn record(&self, code: Option<NodeFailureCode>) {
1468 *self
1469 .latest_code
1470 .lock()
1471 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1472 }
1473}
1474
1475#[cfg(all(feature = "fixture", windows))]
1476struct IsolatedCodexHomeFixtureEnvironment;
1477
1478#[cfg(all(feature = "fixture", windows))]
1479impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1480 for IsolatedCodexHomeFixtureEnvironment
1481{
1482 fn resolve_child_environment(
1483 &self,
1484 ) -> Result<
1485 Vec<gate4agent_catalog::EnvMutation>,
1486 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1487 > {
1488 Ok(vec![gate4agent_catalog::EnvMutation {
1489 key: OsString::from("USERPROFILE"),
1490 value: None,
1491 }])
1492 }
1493}
1494
1495impl NodeServer {
1496 #[cfg(unix)]
1500 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1501 self.shared.signer_proxy.set(proxy).is_ok()
1502 }
1503
1504 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1505 self.shared.m4a_accounts.as_ref()?;
1506 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1507 }
1508
1509 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1510 let catalog = active_registry()?;
1511 Self::new_with_registry(config, catalog)
1512 }
1513
1514 #[cfg(feature = "fixture")]
1515 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1516 let mut spec = gate4agent_testkit::interactive_agent_spec();
1517 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1518 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1519 Self::new_fixture_with_spec(config, spec)
1520 }
1521
1522 #[cfg(feature = "fixture")]
1523 pub fn new_clean_exit_fixture(
1524 config: NodeServerConfig,
1525 fixture_root: PathBuf,
1526 started_marker: PathBuf,
1527 release_signal: PathBuf,
1528 ) -> Result<Self, NodeServerError> {
1529 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1530 &fixture_root,
1531 &started_marker,
1532 &release_signal,
1533 )
1534 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1535 spec.id = AgentId::new("claude")
1536 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1537 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1538 Self::new_fixture_with_spec(config, spec)
1539 }
1540
1541 #[cfg(all(feature = "fixture", windows))]
1551 pub fn new_durable_context_pack_clean_exit_fixture(
1552 config: NodeServerConfig,
1553 fixture_root: PathBuf,
1554 started_marker: PathBuf,
1555 release_signal: PathBuf,
1556 session_id: &str,
1557 ) -> Result<Self, NodeServerError> {
1558 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1559 &fixture_root,
1560 &started_marker,
1561 &release_signal,
1562 session_id,
1563 )
1564 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1565 let source_id = AgentId::new("claude")
1566 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1567 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1568 NodeServerError::Registry(
1569 "durable context pack clean-exit fixture source adapter is unavailable"
1570 .to_owned(),
1571 )
1572 })?;
1573 source_spec.capabilities.adapters.history =
1574 source_provider.capabilities.adapters.history.clone();
1575 source_spec.id = source_id;
1576 source_spec.display_name =
1577 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1578 source_spec.detection.command =
1584 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1585 source_spec.detection.aliases.clear();
1586
1587 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1588 target_spec.id = AgentId::new("codex")
1589 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1590 target_spec.display_name =
1591 "Controlled Codex durable context-pack target fixture".to_owned();
1592 target_spec.detection.command =
1593 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1594 target_spec.detection.aliases.clear();
1595
1596 let catalog = AgentRegistry::new([source_spec, target_spec])
1597 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598 let mut server = Self::new_with_registry(config, catalog)?;
1599 Arc::get_mut(&mut server.shared)
1600 .expect("fixture Node shared state must remain uniquely owned before run")
1601 .fixture_semantic_hook_policy = true;
1602 Ok(server)
1603 }
1604
1605 #[cfg(feature = "fixture")]
1606 pub fn new_monitoring_hook_fixture(
1607 config: NodeServerConfig,
1608 ) -> Result<Self, NodeServerError> {
1609 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1610 spec.id = AgentId::new("claude")
1611 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1612 let mut server = Self::new_fixture_with_spec(config, spec)?;
1613 Arc::get_mut(&mut server.shared)
1614 .expect("fixture Node shared state must remain uniquely owned before run")
1615 .fixture_semantic_hook_policy = true;
1616 Ok(server)
1617 }
1618
1619 #[cfg(all(feature = "fixture", windows))]
1620 pub fn new_provider_bundle_argv_fixture(
1621 config: NodeServerConfig,
1622 agent_id: AgentId,
1623 proof_path: PathBuf,
1624 ) -> Result<Self, NodeServerError> {
1625 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1626 Self::new_fixture_with_spec(config, spec)
1627 }
1628
1629 #[cfg(all(feature = "fixture", windows))]
1630 pub fn new_provider_bundle_argv_hold_fixture(
1631 config: NodeServerConfig,
1632 agent_id: AgentId,
1633 proof_path: PathBuf,
1634 release_signal: PathBuf,
1635 ) -> Result<Self, NodeServerError> {
1636 let spec = Self::provider_bundle_argv_fixture_spec(
1637 agent_id,
1638 proof_path,
1639 Some(release_signal),
1640 )?;
1641 Self::new_fixture_with_spec(config, spec)
1642 }
1643
1644 #[cfg(all(feature = "fixture", windows))]
1645 fn provider_bundle_argv_fixture_spec(
1646 agent_id: AgentId,
1647 proof_path: PathBuf,
1648 release_signal: Option<PathBuf>,
1649 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1650 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1651 return Err(NodeServerError::Registry(
1652 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1653 ));
1654 }
1655 let mut spec = gate4agent_testkit::interactive_agent_spec();
1656 spec.id = agent_id.clone();
1657 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1658 let provider = builtin_registry()
1659 .get(&agent_id)
1660 .ok_or_else(|| {
1661 NodeServerError::Registry(
1662 "provider bundle argv fixture adapter is unavailable".to_owned(),
1663 )
1664 })?;
1665 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1666 if !proof_path.is_absolute()
1667 || !proof_path
1668 .parent()
1669 .is_some_and(|parent| parent.is_dir())
1670 {
1671 return Err(NodeServerError::Registry(
1672 "provider bundle argv proof path requires an existing absolute parent"
1673 .to_owned(),
1674 ));
1675 }
1676 if let Some(release_signal) = release_signal.as_ref() {
1677 if !release_signal.is_absolute()
1678 || !release_signal
1679 .parent()
1680 .is_some_and(|parent| parent.is_dir())
1681 {
1682 return Err(NodeServerError::Registry(
1683 "provider bundle argv release path requires an existing absolute parent"
1684 .to_owned(),
1685 ));
1686 }
1687 if release_signal.exists() {
1688 return Err(NodeServerError::Registry(
1689 "provider bundle argv release path must not exist".to_owned(),
1690 ));
1691 }
1692 let proof_parent = std::fs::canonicalize(
1693 proof_path
1694 .parent()
1695 .expect("validated provider bundle proof parent"),
1696 )
1697 .map_err(|_| {
1698 NodeServerError::Registry(
1699 "provider bundle argv proof parent is unavailable".to_owned(),
1700 )
1701 })?;
1702 let release_parent = std::fs::canonicalize(
1703 release_signal
1704 .parent()
1705 .expect("validated provider bundle release parent"),
1706 )
1707 .map_err(|_| {
1708 NodeServerError::Registry(
1709 "provider bundle argv release parent is unavailable".to_owned(),
1710 )
1711 })?;
1712 if proof_parent != release_parent {
1713 return Err(NodeServerError::Registry(
1714 "provider bundle argv release path must share the proof parent".to_owned(),
1715 ));
1716 }
1717 }
1718 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1719 NodeServerError::Registry(
1720 "provider bundle argv proof path is not valid Unicode".to_owned(),
1721 )
1722 })?;
1723 let release_signal = release_signal.map(|release_signal| {
1724 release_signal.into_os_string().into_string().map_err(|_| {
1725 NodeServerError::Registry(
1726 "provider bundle argv release path is not valid Unicode".to_owned(),
1727 )
1728 })
1729 }).transpose()?;
1730 let script = spec
1731 .launch
1732 .fixed_args
1733 .pop()
1734 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1735 let bundle_validation = if agent_id.as_str() == "codex" {
1736 "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') }"
1737 } else if agent_id.as_str() == "claude" {
1738 "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 }"
1739 } else {
1740 "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 }"
1741 };
1742 if let Some(release_signal) = release_signal {
1743 spec.launch.fixed_args.push(format!(
1744 "& {{ 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} }}",
1745 ));
1746 spec.launch.fixed_args.push(proof_path);
1747 spec.launch.fixed_args.push(release_signal);
1748 } else {
1749 spec.launch.fixed_args.push(format!(
1750 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1751 ));
1752 spec.launch.fixed_args.push(proof_path);
1753 }
1754 Ok(spec)
1755 }
1756
1757 #[cfg(all(feature = "fixture", windows))]
1758 pub fn new_context_pack_fixture(
1759 config: NodeServerConfig,
1760 proof_path: PathBuf,
1761 ) -> Result<Self, NodeServerError> {
1762 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1763 Self::new_with_registry(config, catalog)
1764 }
1765
1766 #[cfg(all(feature = "fixture", windows))]
1767 pub fn new_monitoring_context_pack_fixture(
1768 config: NodeServerConfig,
1769 proof_path: PathBuf,
1770 ) -> Result<Self, NodeServerError> {
1771 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1772 let mut server = Self::new_with_registry(config, catalog)?;
1773 Arc::get_mut(&mut server.shared)
1774 .expect("fixture Node shared state must remain uniquely owned before run")
1775 .fixture_semantic_hook_policy = true;
1776 Ok(server)
1777 }
1778
1779 #[cfg(all(feature = "fixture", windows))]
1780 pub fn install_isolated_codex_home_environment_fixture(
1781 &mut self,
1782 profile_id: SpawnEnvironmentProfileId,
1783 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1784 ) -> Result<(), NodeServerError> {
1785 let provider = AgentId::new("codex")
1786 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1787 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1788 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1789 "{}-pty",
1790 profile_id.as_str(),
1791 ))
1792 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1793 provider.clone(),
1794 TransportKind::Pty,
1795 vec![OsString::from("USERPROFILE")],
1796 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1797 )
1798 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1799 let materialization = NodeSessionMaterializationProfile::new(
1800 Vec::new(),
1801 vec![crate::session_environment::NodeSessionPathBinding::new(
1802 "CODEX_HOME",
1803 crate::session_environment::NodeSessionPathClass::ProviderHome,
1804 )
1805 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1806 Vec::new(),
1807 )
1808 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1809 self.install_environment_profile(
1810 NodeEnvironmentProfile::new_with_materialization(
1811 profile_id,
1812 profile_revision,
1813 provider,
1814 [native_profile],
1815 Some(materialization),
1816 )
1817 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1818 )
1819 }
1820
1821 #[cfg(all(feature = "fixture", windows))]
1822 pub fn new_context_only_proof_fixture(
1823 config: NodeServerConfig,
1824 target_provider: AgentId,
1825 proof_path: PathBuf,
1826 ) -> Result<Self, NodeServerError> {
1827 if target_provider.as_str() != "kimi" {
1828 return Err(NodeServerError::Registry(
1829 "context-only proof fixture requires Kimi".to_owned(),
1830 ));
1831 }
1832 let catalog = Self::context_pack_fixture_catalog(
1833 proof_path,
1834 Some(&target_provider),
1835 false,
1836 )?;
1837 Self::new_with_registry(config, catalog)
1838 }
1839
1840 #[cfg(all(feature = "fixture", windows))]
1841 fn context_pack_fixture_catalog(
1842 proof_path: PathBuf,
1843 context_only_target: Option<&AgentId>,
1844 monitoring_claude_source: bool,
1845 ) -> Result<AgentRegistry, NodeServerError> {
1846 if !proof_path.is_absolute()
1847 || !proof_path
1848 .parent()
1849 .is_some_and(|parent| parent.is_dir())
1850 {
1851 return Err(NodeServerError::Registry(
1852 "context pack proof path requires an existing absolute parent".to_owned(),
1853 ));
1854 }
1855 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1856 NodeServerError::Registry(
1857 "context pack proof path is not valid Unicode".to_owned(),
1858 )
1859 })?;
1860 let providers = ["claude", "codex", "grok", "kimi"];
1861 let builtins = builtin_registry();
1862 let mut specs = Vec::with_capacity(providers.len());
1863 for provider_id in providers {
1864 let agent_id = AgentId::new(provider_id)
1865 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1866 let provider = builtins.get(&agent_id).ok_or_else(|| {
1867 NodeServerError::Registry(
1868 "context pack fixture provider adapter is unavailable".to_owned(),
1869 )
1870 })?;
1871 let monitored_provider =
1872 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1873 let mut spec = if monitored_provider {
1874 gate4agent_testkit::monitoring_hook_agent_spec()
1875 } else {
1876 gate4agent_testkit::interactive_agent_spec()
1877 };
1878 spec.id = agent_id.clone();
1879 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1880 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1881 spec.detection.aliases.clear();
1882 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1883 if provider_id == "codex" {
1884 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1885 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1886 })?;
1887 let validation = r#"
1888$bundleArgs = @($bundleArgs)
1889if ($bundleArgs.Count -ne 0) { exit 91 }
1890$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1891$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1892$cwd = (Get-Location).ProviderPath
1893if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1894$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1895if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1896$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1897if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1898$sha = [Security.Cryptography.SHA256]::Create()
1899try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1900if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1901if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1902$contextPath = Join-Path $contextRoot 'context-pack.json'
1903if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1904$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1905if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1906$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1907$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1908$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1909$separator = [IO.Path]::DirectorySeparatorChar
1910if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1911if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1912$rawContext = [IO.File]::ReadAllText($contextPath)
1913if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1914try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1915$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1916$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1917foreach ($property in @($document.PSObject.Properties.Name)) {
1918 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1919}
1920foreach ($property in $requiredTopLevel) {
1921 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1922}
1923if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1924if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1925$messages = @($document.retained_messages)
1926if ($messages.Count -eq 0) { exit 107 }
1927foreach ($message in $messages) {
1928 $messageProperties = @($message.PSObject.Properties.Name)
1929 foreach ($property in $messageProperties) {
1930 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1931 }
1932 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1933}
1934$sha = [Security.Cryptography.SHA256]::Create()
1935try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1936[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))
1937$rawProof = [IO.File]::ReadAllText($proofPath)
1938if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1939[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1940"#;
1941 spec.launch.fixed_args.push(format!(
1942 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1943 ));
1944 spec.launch.fixed_args.push(proof_path.clone());
1945 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1946 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1947 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1948 })?;
1949 let validation = r#"
1950$bundleArgs = @($bundleArgs)
1951if ($bundleArgs.Count -ne 0) { exit 91 }
1952$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1953$cwd = (Get-Location).ProviderPath
1954if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1955$contextPath = Join-Path $contextRoot 'context-pack.json'
1956if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1957$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1958if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1959$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1960$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1961$separator = [IO.Path]::DirectorySeparatorChar
1962if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1963try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1964if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1965if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1966$messages = @($document.retained_messages)
1967if ($messages.Count -eq 0) { exit 99 }
1968$hasUser = $false
1969$hasAssistant = $false
1970foreach ($message in $messages) {
1971 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1972 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1973 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1974 else { exit 101 }
1975}
1976if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1977$sha = [Security.Cryptography.SHA256]::Create()
1978try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1979[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1980[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1981"#;
1982 spec.launch.fixed_args.push(format!(
1983 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1984 ));
1985 spec.launch.fixed_args.push(proof_path.clone());
1986 }
1987 specs.push(spec);
1988 }
1989 let catalog = AgentRegistry::new(specs)
1990 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1991 Ok(catalog)
1992 }
1993
1994 #[cfg(feature = "fixture")]
1995 pub fn new_exact_launcher_fixture(
1996 config: NodeServerConfig,
1997 agent_id: AgentId,
1998 launcher: String,
1999 ) -> Result<Self, NodeServerError> {
2000 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
2001 return Err(NodeServerError::Registry(
2002 "exact launcher fixture requires a supported provider".to_owned(),
2003 ));
2004 }
2005 if launcher.contains('\0') {
2006 return Err(NodeServerError::Registry(
2007 "exact launcher fixture path contains NUL".to_owned(),
2008 ));
2009 }
2010 let launcher_path = Path::new(&launcher);
2011 if !launcher_path.is_absolute() {
2012 return Err(NodeServerError::Registry(
2013 "exact launcher fixture path must be absolute".to_owned(),
2014 ));
2015 }
2016 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2017 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2018 })?;
2019 if !metadata.is_file() {
2020 return Err(NodeServerError::Registry(
2021 "exact launcher fixture path must be a regular file".to_owned(),
2022 ));
2023 }
2024 let mut spec = builtin_registry()
2025 .get(&agent_id)
2026 .ok_or_else(|| {
2027 NodeServerError::Registry(
2028 "exact launcher fixture provider is unavailable".to_owned(),
2029 )
2030 })?
2031 .clone();
2032 spec.launch.program = launcher;
2033 Self::new_fixture_with_spec(config, spec)
2034 }
2035
2036 #[cfg(feature = "fixture")]
2045 fn h3b_provider_spec_with_program_and_args(
2046 agent_id: AgentId,
2047 provider_program: PathBuf,
2048 fixed_args: Vec<OsString>,
2049 ) -> Result<AgentSpec, NodeServerError> {
2050 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2051 return Err(NodeServerError::Registry(
2052 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2053 ));
2054 }
2055 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2056 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2057 })?;
2058 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2059 return Err(NodeServerError::Registry(
2060 "H3B fixture provider must be an exact regular file".to_owned(),
2061 ));
2062 }
2063 let program = provider_program.into_os_string().into_string().map_err(|_| {
2064 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2065 })?;
2066 let fixed_args = fixed_args.into_iter().map(|argument| {
2067 argument.into_string().map_err(|_| NodeServerError::Registry(
2068 "H3B fixture argument is not Unicode".to_owned(),
2069 ))
2070 }).collect::<Result<Vec<_>, _>>()?;
2071 let argument_bytes = fixed_args.iter()
2072 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2073 if program.contains('\0')
2074 || fixed_args.iter().any(|argument| argument.contains('\0'))
2075 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2076 {
2077 return Err(NodeServerError::Registry(
2078 "H3B fixture provider or arguments are invalid".to_owned(),
2079 ));
2080 }
2081 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2082 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2083 })?.clone();
2084 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2085 .ok_or_else(|| NodeServerError::Registry(
2086 "H3B fixture provider basename is unavailable".to_owned(),
2087 ))?.to_owned();
2088 spec.detection.command = process_name.clone();
2089 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2090 name: process_name,
2091 }];
2092 spec.launch.program = program;
2093 spec.launch.fixed_args = fixed_args;
2094 Ok(spec)
2095 }
2096
2097 #[cfg(feature = "fixture")]
2098 pub fn new_harness_mcp_proxy_fixture(
2099 config: NodeServerConfig,
2100 provider_program: PathBuf,
2101 fixed_args: Vec<OsString>,
2102 harness_mcp_program: PathBuf,
2103 ) -> Result<Self, NodeServerError> {
2104 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2105 let agent_id = AgentId::new("codex")
2106 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2107 let mut spec =
2108 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2109 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2110 config.fixture_raw_pty_runtime = true;
2111 Self::new_fixture_with_spec(config, spec)
2112 }
2113
2114 #[cfg(feature = "fixture")]
2138 pub fn new_harness_mcp_acp_launcher_fixture(
2139 config: NodeServerConfig,
2140 helper_program: PathBuf,
2141 provider: AgentId,
2142 program: PathBuf,
2143 args: Vec<OsString>,
2144 ) -> Result<Self, NodeServerError> {
2145 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2146 return Err(NodeServerError::Registry(
2147 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2148 ));
2149 }
2150 let config = config.with_harness_mcp_helper(helper_program)?;
2151 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2152 let launch = spec.launch.clone();
2153 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2154 NodeServerError::Registry(
2155 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2156 .to_owned(),
2157 )
2158 })?;
2159 acp.launch_override = Some(launch);
2167 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2168 Self::new_fixture_with_spec(config, spec)
2169 }
2170
2171 #[cfg(feature = "fixture")]
2172 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2173 let mut spec = gate4agent_testkit::interactive_agent_spec();
2174 let claude_id = AgentId::new("claude")
2175 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2176 let claude = builtin_registry()
2177 .get(&claude_id)
2178 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2179 spec.id = claude_id;
2180 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2181 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2182 name: "claude".to_owned(),
2183 }];
2184 spec.capabilities.transports.pty_adapter = claude
2185 .capabilities
2186 .transports
2187 .pty_adapter
2188 .clone();
2189 spec.capabilities.adapters.resume = claude
2190 .capabilities
2191 .adapters
2192 .resume
2193 .clone();
2194 let script = spec
2195 .launch
2196 .fixed_args
2197 .pop()
2198 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2199 spec.launch.fixed_args.push(format!(
2200 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2201 ));
2202 Self::new_fixture_with_spec(config, spec)
2203 }
2204
2205 #[cfg(feature = "fixture")]
2206 pub fn new_cmd_cwd_fixture(
2207 config: NodeServerConfig,
2208 command: String,
2209 ) -> Result<Self, NodeServerError> {
2210 let mut spec = gate4agent_testkit::interactive_agent_spec();
2211 spec.id = AgentId::new("claude")
2212 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2214 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2215 spec.launch.program = command;
2216 spec.launch.fixed_args.clear();
2217 Self::new_fixture_with_spec(config, spec)
2218 }
2219
2220 #[cfg(feature = "fixture")]
2221 fn new_fixture_with_spec(
2222 config: NodeServerConfig,
2223 spec: gate4agent_types::AgentSpec,
2224 ) -> Result<Self, NodeServerError> {
2225 let catalog = AgentRegistry::new([spec])
2226 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2227 Self::new_with_registry(config, catalog)
2228 }
2229
2230 fn new_with_registry(
2231 config: NodeServerConfig,
2232 catalog: AgentRegistry,
2233 ) -> Result<Self, NodeServerError> {
2234 for profile in config.spawn_profiles.iter() {
2235 if catalog.get(&profile.provider).is_none() {
2236 return Err(NodeServerError::Registry(
2237 "spawn profile references an unavailable provider".to_owned(),
2238 ));
2239 }
2240 }
2241 let input_settle_timeout_ms = catalog
2242 .iter()
2243 .map(|spec| spec.readiness.timeout_ms)
2244 .max()
2245 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2246 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2247 .max(MUTATION_SETTLE_TIMEOUT_MS);
2248 let (provider_contracts, provider_adapter_contracts) =
2249 provider_contract_manifest(&catalog)?;
2250 let compatibility_support = node_compatibility_support_for_manifest(
2251 &provider_contracts,
2252 &provider_adapter_contracts,
2253 )?;
2254 validate_node_negotiated_handshake_capacity(&compatibility_support)
2255 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2256 let enabled_providers = provider_contracts
2257 .iter()
2258 .map(|contract| contract.provider.clone())
2259 .collect();
2260 #[cfg(feature = "fixture")]
2261 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2262 #[cfg(not(feature = "fixture"))]
2263 let fixture_raw_pty_runtime = false;
2264 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2265 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2266 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2267 })).expect("fixture provider catalog remains bounded");
2268 (None, statuses)
2269 } else {
2270 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2271 let statuses = monitor.collect();
2272 (Some(monitor), statuses)
2273 };
2274 let state_path_lock =
2275 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2276 .map_err(|error| {
2277 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2278 })?;
2279 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2280 Some(state_path) => {
2281 let delivery_root = delivery_store_root_for_state_path(state_path)
2282 .ok_or_else(|| {
2283 NodeServerError::DeliveryStore(
2284 "delivery store root could not be derived from state path".to_owned(),
2285 )
2286 })?;
2287 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2288 let detail = match &error {
2289 DeliveryStoreError::Storage(io_error) => {
2290 format!("{error}: {io_error}")
2291 }
2292 _ => error.to_string(),
2293 };
2294 NodeServerError::DeliveryStore(detail)
2295 })?;
2296 (Some(store), bundles)
2297 }
2298 None => (None, Vec::new()),
2299 };
2300 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2301 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2302 })?;
2303 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2304 Some(state_path) => {
2305 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2306 .ok_or(NodeServerError::ContextPackStore)?;
2307 let (store, packs) = ContextPackStore::open(context_pack_root)
2308 .map_err(|_| NodeServerError::ContextPackStore)?;
2309 (Some(store), packs)
2310 }
2311 None => (None, Vec::new()),
2312 };
2313 let mut durable_context_catalog = ContextPackCatalog::default();
2314 for pack in durable_context_packs {
2315 durable_context_catalog
2316 .insert(pack)
2317 .map_err(|_| NodeServerError::ContextPackStore)?;
2318 }
2319 let session_environment_materializer = config
2320 .session_environment
2321 .as_ref()
2322 .map(|environment| {
2323 SessionEnvironmentMaterializer::new(
2324 environment.root.clone(),
2325 Arc::clone(&environment.resolver),
2326 )
2327 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2328 })
2329 .transpose()?;
2330 #[cfg(unix)]
2333 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2334 let native_session_catalog = config.history.clone().map(|history| {
2335 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2336 });
2337 let (handle, runtime) = match config.history.clone() {
2338 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2339 None => NativeRuntime::new(catalog, config.runtime),
2340 };
2341 let native_launch_profile_control = runtime.native_launch_profile_control();
2342 let harness_mcp_registry = config
2343 .harness_mcp_helper
2344 .clone()
2345 .map(HarnessMcpProxyRegistry::new);
2346 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2347 let incarnation_id = random_incarnation_id()
2348 .map_err(NodeServerError::IncarnationIdentity)?;
2349 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2350 .map_err(durable_state_load_error)?;
2351 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2352 let managed_worktree_tombstones =
2353 std::mem::take(&mut loaded.managed_worktree_tombstones);
2354 let materializations = std::mem::take(&mut loaded.materializations);
2355 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2356 let (workspaces, records, persistence_warning) =
2357 merge_durable_state(&config.workspaces, loaded)?;
2358 let mut shared = NodeShared::new_with_incarnation(
2359 handle,
2360 config.access_token.clone(),
2361 config.node_id.clone(),
2362 incarnation_id,
2363 workspaces,
2364 enabled_providers,
2365 provider_runtime_statuses,
2366 provider_runtime_monitor,
2367 provider_contracts,
2368 provider_adapter_contracts,
2369 config.spawn_profiles.clone(),
2370 Some(native_launch_profile_control),
2371 native_session_catalog,
2372 session_environment_materializer,
2373 config.state_path.clone(),
2374 records,
2375 managed_worktrees,
2376 managed_worktree_tombstones,
2377 materializations,
2378 persistence_warning,
2379 input_settle_timeout_ms,
2380 );
2381 shared.harness_mcp_registry = harness_mcp_registry.clone();
2382 shared.session_record_retention = config.session_record_retention;
2383 shared.network_allowlist_catalog =
2384 RwLock::new(config.network_allowlist_catalog.clone());
2385 shared.bundle_catalog = RwLock::new(delivered_catalog);
2386 shared.delivery_store = Mutex::new(delivery_store);
2387 shared.context_catalog = RwLock::new(durable_context_catalog);
2388 shared.context_pack_store = Mutex::new(context_pack_store);
2389 shared.managed_spawn_replays = Mutex::new(
2390 managed_spawn_replays
2391 .into_iter()
2392 .map(|record| (record.idempotency_key.clone(), record))
2393 .collect(),
2394 );
2395 let shared = Arc::new(shared);
2396 if let Some(registry) = harness_mcp_registry {
2397 let weak = Arc::downgrade(&shared);
2398 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2399 Some(shared) => shared.publish_transient(event),
2400 None => false,
2401 }));
2402 }
2403 if shared.persistence_error().is_none() {
2404 shared.persist_state().map_err(|error| {
2405 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2406 })?;
2407 }
2408 Ok(Self {
2409 config,
2410 runtime,
2411 shared,
2412 events,
2413 state_path_lock,
2414 })
2415 }
2416
2417 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2418 NodeShutdownHandle {
2419 shared: Arc::clone(&self.shared),
2420 }
2421 }
2422
2423 #[cfg(feature = "fixture")]
2424 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2425 &self,
2426 ) -> SpawnManagedWorktreeV2FailureProbe {
2427 self.shared
2428 .fixture_spawn_managed_worktree_v2_failure_probe
2429 .clone()
2430 }
2431
2432 pub fn install_environment_profile(
2437 &mut self,
2438 profile: NodeEnvironmentProfile,
2439 ) -> Result<(), NodeServerError> {
2440 if !self.shared.enabled_providers.contains(profile.provider()) {
2441 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2442 profile.provider().clone(),
2443 ));
2444 }
2445 let (binding, native_profiles, materialization) = profile.into_parts();
2446 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2447 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2448 }
2449 let mut profiles = self
2450 .shared
2451 .environment_profiles
2452 .write()
2453 .unwrap_or_else(|poisoned| poisoned.into_inner());
2454 if profiles.contains_key(&binding.id) {
2455 return Err(NodeServerError::DuplicateEnvironmentProfile(
2456 binding.id.clone(),
2457 ));
2458 }
2459 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2460 return Err(NodeServerError::EnvironmentProfileCapacity {
2461 max: MAX_NODE_ENVIRONMENT_PROFILES,
2462 });
2463 }
2464 for native_id in binding.native_profile_ids() {
2465 if profiles.values().any(|current| {
2466 current.native_profile_ids().any(|current_id| current_id == native_id)
2467 }) {
2468 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2469 native_id.to_string(),
2470 ));
2471 }
2472 }
2473
2474 let mut installed = Vec::with_capacity(native_profiles.len());
2475 for native_profile in native_profiles {
2476 let native_id = native_profile.id().clone();
2477 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2478 for installed_id in installed.into_iter().rev() {
2479 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2480 }
2481 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2482 }
2483 installed.push(native_id);
2484 }
2485 if let Some(materialization) = materialization {
2486 self.shared
2487 .environment_materialization_profiles
2488 .write()
2489 .unwrap_or_else(|poisoned| poisoned.into_inner())
2490 .insert(binding.id.clone(), materialization);
2491 }
2492 profiles.insert(binding.id.clone(), binding);
2493 Ok(())
2494 }
2495
2496 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2498 if self.shared.session_environment_materializer.is_none() {
2499 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2500 }
2501 let mut catalog = self
2502 .shared
2503 .bundle_catalog
2504 .write()
2505 .unwrap_or_else(|poisoned| poisoned.into_inner());
2506 catalog
2507 .insert_idempotent(bundle)
2508 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2509 Ok(())
2510 }
2511
2512 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2513 let shutdown = self.shutdown_handle();
2514 let run = self.run();
2515 tokio::pin!(run);
2516 tokio::select! {
2517 result = &mut run => result,
2518 signal = wait_for_ctrl_signal() => {
2519 signal?;
2520 let shutdown_result = shutdown.request_shutdown().await;
2521 let run_result = run.await;
2522 shutdown_result.and(run_result)
2523 }
2524 }
2525 }
2526
2527 pub async fn run(self) -> Result<(), NodeServerError> {
2528 let NodeServer {
2529 config,
2530 mut runtime,
2531 shared,
2532 events,
2533 state_path_lock: _state_path_lock,
2534 } = self;
2535 shared.reconcile_materializations();
2536 shared.reconcile_managed_worktrees().await;
2537 shared.retire_unavailable_session_records().await;
2538 shared.reconcile_context_pack_exports().await;
2539 {
2540 let restore = Arc::clone(&shared);
2541 tokio::spawn(async move {
2542 sleep(Duration::from_secs(3)).await;
2543 restore.restore_journalled_sessions().await;
2544 loop {
2545 restore.refresh_session_journal();
2546 if restore.shutdown.load(Ordering::Acquire) { break; }
2547 sleep(Duration::from_secs(10)).await;
2548 }
2549 });
2550 }
2551 let endpoint = config.endpoint.clone();
2555 let call_home = config.call_home;
2556 let call_home_identity = config.call_home_identity.clone();
2557 let api_listen = config.api_listen;
2558 let bridge_listen = config.bridge_listen;
2559 let bridge_underlay = config.bridge_underlay;
2560 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2561 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2562 let accept_shared = Arc::clone(&shared);
2563 let api_shared = Arc::clone(&shared);
2564 let bridge_shared = Arc::clone(&shared);
2565 let shutdown_shared = Arc::clone(&shared);
2566 let shutdown_timeout = Duration::from_millis(
2567 config.runtime.provider_shutdown_timeout_ms.max(1),
2568 );
2569 let result = {
2570 let runtime_loop = drive_runtime_until_shutdown(
2571 &mut runtime,
2572 events,
2573 Arc::clone(&shared),
2574 shutdown_timeout,
2575 );
2576 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2577 let api_loop = http_api::run(api_listen, api_shared);
2578 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2579 tokio::pin!(runtime_loop);
2580 tokio::pin!(accept_loop);
2581 tokio::pin!(api_loop);
2582 tokio::pin!(bridge_loop);
2583 tokio::select! {
2584 runtime_result = &mut runtime_loop => {
2585 let shutdown_result = shutdown_shared
2586 .begin_shutdown()
2587 .await
2588 .map_err(NodeServerError::ShutdownDispatch);
2589 let (accept_result, api_result, bridge_result) =
2590 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2591 match runtime_result {
2592 Err(error) => Err(error),
2593 Ok(()) => shutdown_result
2594 .and(accept_result)
2595 .and(api_result.map_err(NodeServerError::HttpApi))
2596 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2597 }
2598 }
2599 accept_result = &mut accept_loop => {
2600 let shutdown_result = shutdown_shared
2601 .begin_shutdown()
2602 .await
2603 .map_err(NodeServerError::ShutdownDispatch);
2604 let (runtime_result, api_result, bridge_result) =
2605 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2606 match accept_result {
2607 Err(error) => Err(error),
2608 Ok(()) => shutdown_result
2609 .and(runtime_result)
2610 .and(api_result.map_err(NodeServerError::HttpApi))
2611 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2612 }
2613 }
2614 api_result = &mut api_loop => {
2615 let shutdown_result = shutdown_shared
2616 .begin_shutdown()
2617 .await
2618 .map_err(NodeServerError::ShutdownDispatch);
2619 let (runtime_result, accept_result, bridge_result) =
2620 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2621 match api_result {
2622 Err(error) => Err(NodeServerError::HttpApi(error)),
2623 Ok(()) => shutdown_result
2624 .and(runtime_result)
2625 .and(accept_result)
2626 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2627 }
2628 }
2629 bridge_result = &mut bridge_loop => {
2630 let shutdown_result = shutdown_shared
2631 .begin_shutdown()
2632 .await
2633 .map_err(NodeServerError::ShutdownDispatch);
2634 let (runtime_result, accept_result, api_result) =
2635 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2636 match bridge_result {
2637 Err(error) => Err(NodeServerError::BridgeApi(error)),
2638 Ok(()) => shutdown_result
2639 .and(runtime_result)
2640 .and(accept_result)
2641 .and(api_result.map_err(NodeServerError::HttpApi)),
2642 }
2643 }
2644 }
2645 };
2646 if let Ok(mut guard) = shared.delivery_store.lock() {
2653 *guard = None;
2654 }
2655 if let Ok(mut guard) = shared.context_pack_store.lock() {
2656 *guard = None;
2657 }
2658 result
2659 }
2660}
2661
2662async fn drive_runtime_until_shutdown(
2663 runtime: &mut NativeRuntime,
2664 events: EventSubscription,
2665 shared: Arc<NodeShared>,
2666 shutdown_timeout: Duration,
2667) -> Result<(), NodeServerError> {
2668 let _ = shared
2672 .shell_efficiency_profile
2673 .set(runtime.shell_efficiency_profile());
2674 let mut shutdown_started = None;
2675 let mut last_retention_sweep = Instant::now();
2676 let mut last_provisional_reap = Instant::now();
2677 loop {
2678 let runtime_tick_start = Instant::now();
2685 let tick_result = runtime.tick().await;
2686 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2687
2688 let event_drain_start = Instant::now();
2689 for outcome in &tick_result.command_outcomes {
2697 if let Err(error) = &outcome.result {
2698 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2699 tracing::warn!(
2700 command_id = outcome.command_id.0,
2701 cause = %error,
2702 "control command rejected"
2703 );
2704 shared.record_rejected_command(
2705 outcome.command_id,
2706 error.to_string(),
2707 error.is_unsupported_transport(),
2708 );
2709 }
2710 }
2711 let mut events_drained: u64 = 0;
2712 while let Ok(event) = events.try_recv() {
2713 events_drained += 1;
2714 let clean_exit = matches!(
2715 event.event,
2716 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2717 );
2718 let instance_id = event.instance_id;
2719 let generation = event.generation;
2720 if clean_exit {
2732 if let Some((record_id, session)) =
2733 shared.managed_record_export_target_for_instance(instance_id, generation)
2734 {
2735 let export_shared = Arc::clone(&shared);
2736 tokio::spawn(async move {
2737 export_shared
2738 .reconcile_context_pack_export_for_record(&record_id, session)
2739 .await;
2740 });
2741 }
2742 }
2743 shared.publish_control(event);
2744 }
2745 let event_drain_elapsed = event_drain_start.elapsed();
2746
2747 let publish_terminal_frames_start = Instant::now();
2748 shared.publish_terminal_frames();
2749 shared.run_due_provider_retries();
2750 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2751
2752 shared
2758 .native_session_gauge
2759 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2760 *shared
2761 .runtime_tick_profile
2762 .lock()
2763 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2764
2765 let remainder_start = Instant::now();
2766 if shared.shutdown.load(Ordering::Acquire) {
2767 let started = *shutdown_started.get_or_insert_with(Instant::now);
2768 let snapshot = shared.handle.snapshot();
2769 let sessions_settled = snapshot.sessions.iter().all(|session| {
2770 !matches!(
2771 session.status,
2772 gate4agent_types::SessionStatus::Starting
2773 | gate4agent_types::SessionStatus::Running
2774 | gate4agent_types::SessionStatus::Stopping
2775 )
2776 });
2777 if runtime.active_native_sessions() == 0 && sessions_settled {
2778 return Ok(());
2779 }
2780 if started.elapsed() >= shutdown_timeout {
2781 return Err(NodeServerError::ShutdownTimedOut {
2782 active_native_sessions: runtime.active_native_sessions(),
2783 });
2784 }
2785 }
2786 let remainder_elapsed = remainder_start.elapsed();
2787
2788 {
2789 let mut profile = shared
2790 .drive_loop_profile
2791 .lock()
2792 .unwrap_or_else(|poisoned| poisoned.into_inner());
2793 profile.record_runtime_tick(runtime_tick_elapsed);
2794 profile.record_event_drain(event_drain_elapsed);
2795 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2796 profile.record_remainder(remainder_elapsed);
2797 profile.note_iteration(Instant::now());
2798 }
2799
2800 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2812 if tick_result.observations_applied == 0 && events_drained == 0 {
2813 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2814 }
2815
2816 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2821 shared.retire_unavailable_session_records().await;
2822 last_retention_sweep = Instant::now();
2823 }
2824 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2826 for address in shared.take_due_provisional_spawns() {
2827 shared.reap_provisional_spawn(address).await;
2828 }
2829 last_provisional_reap = Instant::now();
2830 }
2831
2832 let hot = shared
2835 .pty_input_marks
2836 .lock()
2837 .unwrap_or_else(|poisoned| poisoned.into_inner())
2838 .values()
2839 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2840 if hot {
2841 sleep(Duration::from_millis(1)).await;
2842 } else {
2843 tokio::select! {
2844 _ = sleep(Duration::from_millis(10)) => {}
2845 _ = shared.pty_input_wake.notified() => {}
2846 }
2847 }
2848 }
2849}
2850
2851#[derive(Clone)]
2852pub struct NodeShutdownHandle {
2853 shared: Arc<NodeShared>,
2854}
2855
2856impl NodeShutdownHandle {
2857 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2858 self.shared
2859 .begin_shutdown()
2860 .await
2861 .map_err(NodeServerError::ShutdownDispatch)
2862 }
2863}
2864
2865async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2866 #[cfg(windows)]
2867 {
2868 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2869 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2870 return tokio::select! {
2871 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2873 };
2874 }
2875 #[cfg(unix)]
2876 {
2877 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2878 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2879 tokio::select! {
2880 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2881 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2882 }
2883 }
2884}
2885
2886fn shell_host_spec() -> AgentSpec {
2900 #[cfg(windows)]
2904 let (detect_command, program, fixed_args, process_name) = {
2905 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2906 r"C:\Windows\System32\cmd.exe".to_owned()
2907 });
2908 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2909 };
2910 #[cfg(not(windows))]
2911 let (detect_command, program, fixed_args, process_name) = (
2912 "bash".to_owned(),
2913 "/bin/bash".to_owned(),
2914 vec!["-i".to_owned()],
2915 "bash".to_owned(),
2916 );
2917 AgentSpec {
2918 id: AgentId::new("shell").expect("shell agent id"),
2919 revision: "hatchery:shell-pty-1".to_owned(),
2920 display_name: "Host shell".to_owned(),
2921 detection: DetectionSpec {
2922 command: detect_command,
2923 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2924 required_commands: Vec::new(),
2925 unsupported_platforms: Vec::new(),
2926 },
2927 launch: LaunchSpec {
2928 program,
2929 fixed_args,
2930 },
2931 expected_processes: vec![ProcessMatcher::Exact {
2932 name: process_name,
2933 }],
2934 prompt: PromptSpec {
2935 initial: InitialPromptMode::None,
2936 native_draft: None,
2937 },
2938 readiness: AgentReadinessSpec::default(),
2939 capabilities: AgentCapabilities {
2940 agent_commands: None,
2941 transports: AgentTransportCapabilities {
2942 pty: true,
2943 pty_adapter: None,
2944 pipe: None,
2945 acp: None,
2946 },
2947 adapters: AgentAdapterCapabilities::default(),
2948 },
2949 verification: SpecVerification::Gate4AgentVerified,
2950 }
2951}
2952
2953fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2954 let mut specs = vec![shell_host_spec()];
2955 specs.extend(
2956 builtin_registry()
2957 .iter()
2958 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2959 .cloned(),
2960 );
2961 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2962}
2963
2964fn provider_contract_manifest(
2965 catalog: &AgentRegistry,
2966) -> Result<
2967 (
2968 Vec<ProviderContractSupport>,
2969 Vec<ProviderAdapterContractSupport>,
2970 ),
2971 NodeServerError,
2972> {
2973 let mut provider_contracts = Vec::new();
2974 let mut provider_adapter_contracts = Vec::new();
2975 for spec in catalog.iter() {
2976 let provider = spec.id.clone();
2977 let revision = ProviderContractRevision::new(spec.revision.clone())
2978 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2979 provider_contracts.push(ProviderContractSupport {
2980 provider: provider.clone(),
2981 revision,
2982 });
2983 for (family, binding) in declared_adapter_bindings(spec)? {
2984 let revision = AdapterContractRevision::new(binding.revision.clone())
2985 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2987 provider: provider.clone(),
2988 family,
2989 adapter_id: binding.id.clone(),
2990 revision,
2991 });
2992 }
2993 }
2994 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2995 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2996 Ok((provider_contracts, provider_adapter_contracts))
2997}
2998
2999fn declared_adapter_bindings(
3000 spec: &AgentSpec,
3001) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
3002 let mut bindings = Vec::new();
3003 push_declared_binding(
3004 &mut bindings,
3005 AdapterFamily::PtySemantic,
3006 spec.capabilities.transports.pty_adapter.as_ref(),
3007 )?;
3008 push_declared_binding(
3009 &mut bindings,
3010 AdapterFamily::Pipe,
3011 spec.capabilities.adapters.pty_sidecar.as_ref(),
3012 )?;
3013 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3014 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3015 gate4agent_types::PipeProtocol::SemanticNdjson
3016 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3017 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3018 });
3019 push_declared_binding(
3020 &mut bindings,
3021 AdapterFamily::Pipe,
3022 pipe_transport
3023 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3024 .map(|transport| &transport.adapter),
3025 )?;
3026 push_declared_binding(
3027 &mut bindings,
3028 AdapterFamily::Acp,
3029 spec.capabilities
3030 .transports
3031 .acp
3032 .as_ref()
3033 .map(|transport| &transport.adapter),
3034 )?;
3035 push_declared_binding(
3036 &mut bindings,
3037 AdapterFamily::Hook,
3038 spec.capabilities.adapters.hook.as_ref(),
3039 )?;
3040 push_declared_binding(
3041 &mut bindings,
3042 AdapterFamily::ManagedHook,
3043 spec.capabilities.adapters.managed_hook.as_ref(),
3044 )?;
3045 push_declared_binding(
3046 &mut bindings,
3047 AdapterFamily::OneShot,
3048 pipe_transport
3049 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3050 .map(|transport| &transport.adapter),
3051 )?;
3052 push_declared_binding(
3053 &mut bindings,
3054 AdapterFamily::OneShot,
3055 spec.capabilities.adapters.one_shot.as_ref(),
3056 )?;
3057 push_declared_binding(
3058 &mut bindings,
3059 AdapterFamily::History,
3060 spec.capabilities.adapters.history.as_ref(),
3061 )?;
3062 push_declared_binding(
3063 &mut bindings,
3064 AdapterFamily::Resume,
3065 spec.capabilities.adapters.resume.as_ref(),
3066 )?;
3067 push_declared_binding(
3068 &mut bindings,
3069 AdapterFamily::SessionOptions,
3070 spec.capabilities.adapters.session_options.as_ref(),
3071 )?;
3072 push_declared_binding(
3073 &mut bindings,
3074 AdapterFamily::CapabilityProbe,
3075 spec.capabilities.adapters.capability_probe.as_ref(),
3076 )?;
3077 Ok(bindings)
3078}
3079
3080fn push_declared_binding<'a>(
3081 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3082 family: AdapterFamily,
3083 binding: Option<&'a AdapterBinding>,
3084) -> Result<(), NodeServerError> {
3085 let Some(binding) = binding else {
3086 return Ok(());
3087 };
3088 if let Some((_, existing)) = bindings
3089 .iter()
3090 .find(|(existing_family, _)| *existing_family == family)
3091 {
3092 if *existing == binding {
3093 return Ok(());
3094 }
3095 return Err(NodeServerError::ProviderContractManifest(format!(
3096 "provider declares conflicting {family:?} adapter bindings"
3097 )));
3098 }
3099 bindings.push((family, binding));
3100 Ok(())
3101}
3102
3103fn merge_durable_state(
3104 configured: &[WorkspaceConfig],
3105 mut loaded: LoadedNodeState,
3106) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3107 let mut workspaces = configured
3108 .iter()
3109 .cloned()
3110 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3111 .collect::<BTreeMap<_, _>>();
3112 let mut roots = workspaces
3113 .values()
3114 .map(|workspace| {
3115 (
3116 platform::root_identity(&workspace.canonical_root),
3117 workspace.workspace_id.clone(),
3118 )
3119 })
3120 .collect::<BTreeMap<_, _>>();
3121 let mut warning = loaded
3122 .warning
3123 .take()
3124 .map(|message| session_registry::sanitized_persistence_summary(&message));
3125
3126 for (workspace_id, canonical_root) in loaded.workspaces {
3127 if let Some(existing) = workspaces.get(&workspace_id) {
3128 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3129 return Err(NodeServerError::DurableState(io::Error::new(
3130 io::ErrorKind::InvalidData,
3131 DURABLE_STATE_CONFLICT_ERROR,
3132 )));
3133 }
3134 continue;
3135 }
3136 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3137 return Err(NodeServerError::DurableState(io::Error::new(
3138 io::ErrorKind::InvalidData,
3139 DURABLE_STATE_CONFLICT_ERROR,
3140 )));
3141 }
3142 if !Path::new(&canonical_root).is_dir() {
3143 warning.get_or_insert_with(|| {
3144 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3145 });
3146 continue;
3147 }
3148 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3149 NodeServerError::DurableState(io::Error::new(
3150 io::ErrorKind::InvalidData,
3151 DURABLE_STATE_CONFLICT_ERROR,
3152 ))
3153 })?;
3154 roots.insert(
3155 platform::root_identity(&workspace.canonical_root),
3156 workspace.workspace_id.clone(),
3157 );
3158 workspaces.insert(workspace_id, workspace);
3159 }
3160
3161 for record in &mut loaded.records {
3162 match workspaces.get(&record.workspace_id) {
3163 Some(workspace)
3164 if platform::roots_equal(
3165 &workspace.canonical_root,
3166 windows_path_text(&record.canonical_root),
3167 ) => {}
3168 Some(_) => {
3169 return Err(NodeServerError::DurableState(io::Error::new(
3170 io::ErrorKind::InvalidData,
3171 DURABLE_STATE_CONFLICT_ERROR,
3172 )));
3173 }
3174 None => {
3175 record.state = ManagedSessionState::Unavailable;
3176 record.active_session = None;
3177 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3178 }
3179 }
3180 }
3181 Ok((workspaces.into_values().collect(), loaded.records, warning))
3182}
3183
3184struct RejectedCommandReason {
3187 message: String,
3188 unsupported_transport: bool,
3195 expires_at: Instant,
3196}
3197
3198struct NodeShared {
3199 handle: Gate4AgentHandle,
3200 access_token: String,
3201 node_id: NodeId,
3202 incarnation_id: NodeIncarnationId,
3203 started_at_unix_ms: u64,
3204 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3205 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3206 managed_worktree_profiles:
3207 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3208 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3209 enabled_providers: Vec<AgentId>,
3210 provider_runtime_statuses: ProviderRuntimeStatuses,
3211 provider_runtime_status_updates:
3212 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3213 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3214 provider_contracts: Vec<ProviderContractSupport>,
3215 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3216 spawn_profiles: SpawnProfileRegistry,
3217 environment_profiles:
3218 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3219 environment_materialization_profiles:
3220 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3221 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3229 #[cfg(feature = "dig2-station-probe")]
3233 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3234 bundle_catalog: RwLock<BundleCatalog>,
3235 delivery_store: Mutex<Option<DeliveryStore>>,
3236 context_pack_store: Mutex<Option<ContextPackStore>>,
3237 context_catalog: RwLock<ContextPackCatalog>,
3238 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3239 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3240 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3241 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3242 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3244 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3246 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3248 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3250 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3252 #[cfg(unix)]
3255 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3256 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3257 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3258 managed_spawn_replays:
3259 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3260 controller: Mutex<Option<ControllerLease>>,
3261 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3264 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3266 history: Mutex<NodeEventHistory>,
3267 event_tx: broadcast::Sender<NodeEventEnvelope>,
3268 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3269 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3277 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3286 terminal_frame_watermarks:
3287 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3288 pty_input_wake: tokio::sync::Notify,
3290 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3293 provider_errors: Mutex<crate::provider_error::Tracker>,
3295 next_connection_id: AtomicU64,
3296 next_instance_id: AtomicU64,
3297 next_command_id: AtomicU64,
3298 shutdown: AtomicBool,
3299 shutdown_notify: Notify,
3300 preauth_slots: Arc<Semaphore>,
3301 authenticated_slots: Arc<Semaphore>,
3302 inspection_slots: Arc<Semaphore>,
3303 mutation_gate: AsyncMutex<()>,
3304 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3305 session_records: Mutex<SessionRecords>,
3306 state_path: Option<PathBuf>,
3307 persistence_error: RwLock<Option<String>>,
3308 state_transaction: Mutex<()>,
3309 input_settle_timeout_ms: u64,
3310 drive_loop_profile: Mutex<DriveLoopProfiler>,
3314 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3320 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3332 native_session_gauge: AtomicUsize,
3339 connection_loop_iterations: AtomicU64,
3350 connection_events_sent: AtomicU64,
3351 rejected_commands_total: AtomicU64,
3360 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3380 drive_loop_iterations_total: AtomicU64,
3381 drive_loop_iterations_idle: AtomicU64,
3382 session_record_retention: SessionRecordRetentionConfig,
3386 retired_records_total: AtomicU64,
3392 #[cfg(feature = "fixture")]
3393 fixture_semantic_hook_policy: bool,
3394 #[cfg(feature = "fixture")]
3395 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3396}
3397
3398impl Drop for NodeShared {
3399 fn drop(&mut self) {
3400 SessionLifecycleHook::on_shutdown(self);
3401 }
3402}
3403
3404#[derive(Clone, Debug, Eq, PartialEq)]
3405struct SessionBinding {
3406 workspace_id: WorkspaceId,
3407 generation: SessionGeneration,
3408 runtime_policy: ProviderRuntimePolicy,
3409 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3410 record_id: Option<SessionRecordId>,
3411 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3412 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3413 bundle: Option<ResolvedBundleReceipt>,
3414 context: Option<ResolvedContextPackReceipt>,
3415 materialization_id: Option<MaterializationId>,
3416}
3417
3418#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3419enum SpawnRecordPolicy {
3420 ProviderIdentityOnly,
3421 Always,
3422}
3423
3424struct NativeEnvironmentSelectionGuard {
3425 control: Option<NativeLaunchProfileControl>,
3426 instance_id: AgentInstanceId,
3427}
3428
3429struct NativeInstanceOverlayGuard {
3430 control: Option<NativeLaunchProfileControl>,
3431 instance_id: AgentInstanceId,
3432}
3433
3434struct NativeHarnessMcpOverlayGuard {
3435 control: NativeLaunchProfileControl,
3436 instance_id: AgentInstanceId,
3437}
3438
3439impl NativeHarnessMcpOverlayGuard {
3440 fn retain(self) { std::mem::forget(self); }
3441}
3442
3443impl Drop for NativeHarnessMcpOverlayGuard {
3444 fn drop(&mut self) {
3445 self.control
3446 .clear_native_mcp_server_launch_overlay(self.instance_id);
3447 }
3448}
3449
3450impl NativeInstanceOverlayGuard {
3451 fn retain(mut self) {
3452 self.control = None;
3453 }
3454}
3455
3456impl Drop for NativeInstanceOverlayGuard {
3457 fn drop(&mut self) {
3458 if let Some(control) = self.control.as_ref() {
3459 control.clear_native_instance_launch_overlay(self.instance_id);
3460 }
3461 }
3462}
3463
3464enum PreparedNativeLaunchOverlay {
3465 Environment(NativeLaunchEnvironmentOverlay),
3466 Instance(NativeInstanceLaunchOverlay),
3467}
3468
3469impl NativeEnvironmentSelectionGuard {
3470 fn retain(mut self) {
3471 self.control = None;
3472 }
3473}
3474
3475impl Drop for NativeEnvironmentSelectionGuard {
3476 fn drop(&mut self) {
3477 if let Some(control) = self.control.as_ref() {
3478 control.clear_native_launch_profile_selection(self.instance_id);
3479 }
3480 }
3481}
3482
3483struct SessionMaterializationGuard<'a> {
3484 shared: &'a NodeShared,
3485 id: Option<MaterializationId>,
3486 provider_home: Option<PathBuf>,
3489}
3490
3491impl SessionMaterializationGuard<'_> {
3492 fn id(&self) -> Option<&MaterializationId> {
3493 self.id.as_ref()
3494 }
3495
3496 fn provider_home(&self) -> Option<&Path> {
3497 self.provider_home.as_deref()
3498 }
3499
3500 fn retain(mut self) {
3501 self.id = None;
3502 }
3503}
3504
3505impl Drop for SessionMaterializationGuard<'_> {
3506 fn drop(&mut self) {
3507 if let Some(id) = self.id.take() {
3508 let _ = self.shared.cleanup_materialization(&id);
3509 }
3510 }
3511}
3512
3513
3514trait SessionLifecycleHook {
3517 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3519 fn on_session_ended(&self, address: &SessionAddress);
3521 fn on_shutdown(&self);
3523 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3525 fn cleanup_session_materialization(
3527 &self,
3528 address: &SessionAddress,
3529 binding: &SessionBinding,
3530 ) -> Result<(), NodeFailure>;
3531}
3532
3533impl SessionLifecycleHook for NodeShared {
3534 fn on_session_ended(&self, address: &SessionAddress) {
3535 self.retire_session_grok_leader(address.session.instance_id);
3536 if !self.shutdown.load(Ordering::Acquire) {
3540 if let Some(file) = self.session_resume_journal() {
3541 let id = address.session.instance_id.0;
3542 let _ = std::thread::Builder::new().name("resume-forget".into()).spawn(move || {
3543 std::thread::sleep(std::time::Duration::from_secs(5));
3544 crate::pty_resume::remove(&file, id);
3545 });
3546 }
3547 }
3548 if let Some(accounts) = self.m4a_accounts.as_ref() {
3549 let instance = address.session.instance_id;
3550 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3551 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3552 accounts.remove_session_file(nick);
3553 }
3554 }
3555 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3556 registry.revoke_session(address);
3557 }
3558 if let Some(control) = self.native_launch_profile_control.as_ref() {
3559 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3560 }
3561 }
3562
3563 fn on_shutdown(&self) {
3564 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3565 .into_values()
3566 .collect();
3567 for socket in leaders {
3568 crate::grok_leader::retire(&socket);
3569 }
3570 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3571 let instances = registry.shutdown();
3572 if let Some(control) = self.native_launch_profile_control.as_ref() {
3573 for instance_id in instances {
3574 control.clear_native_mcp_server_launch_overlay(instance_id);
3575 }
3576 }
3577 }
3578 }
3579
3580 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3581 if let Some(control) = self.native_launch_profile_control.as_ref() {
3582 control.clear_native_mcp_server_launch_overlay(instance_id);
3583 }
3584 }
3585
3586 fn cleanup_session_materialization(
3587 &self,
3588 address: &SessionAddress,
3589 binding: &SessionBinding,
3590 ) -> Result<(), NodeFailure> {
3591 self.cleanup_session_owned_materialization(address, binding)
3592 }
3593
3594 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3595 #[cfg(feature = "dig2-station-probe")]
3598 {
3599 crate::dig2_station_lease::release_lease_for_holder(
3600 &self.browser_station_leases,
3601 address.session.instance_id,
3602 );
3603 }
3604 if let Some(control) = self.native_launch_profile_control.as_ref() {
3605 control.clear_native_mcp_server_launch_overlay(
3606 address.session.instance_id,
3607 );
3608 }
3609 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3610 registry.revoke_session(address);
3611 }
3612 if removed
3613 .is_some_and(|binding| {
3614 binding.environment_profile.is_some()
3615 || binding.bundle.is_some()
3616 || binding.context.is_some()
3617 })
3618 {
3619 if let Some(control) = self.native_launch_profile_control.as_ref() {
3620 control.clear_native_launch_profile_selection(
3621 address.session.instance_id,
3622 );
3623 control.clear_native_instance_launch_overlay(
3624 address.session.instance_id,
3625 );
3626 }
3627 }
3628 }
3629}
3630
3631struct SpawnPrepareRequest<'r> {
3632 instance_id: AgentInstanceId,
3633 address: &'r SessionAddress,
3634 provider: &'r AgentId,
3635 mode: SessionMode,
3636 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3637 bundle: Option<&'r ResolvedBundleReceipt>,
3638 context: Option<&'r ResolvedContextPackReceipt>,
3639 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3640 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3641 approval_level: ApprovalLevel,
3642 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3643 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3644}
3645
3646struct PreparedSpawn<'a> {
3650 instance_overlay: Option<NativeInstanceOverlayGuard>,
3651 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3652 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3653 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3654 #[cfg(feature = "dig2-station-probe")]
3655 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3656 materialization_id: Option<MaterializationId>,
3657}
3658
3659impl PreparedSpawn<'_> {
3660 fn retain_session_guards(&mut self) {
3662 if let Some(selection) = self.environment_selection.take() {
3663 selection.retain();
3664 }
3665 if let Some(overlay) = self.instance_overlay.take() {
3666 overlay.retain();
3667 }
3668 if let Some(guard) = self.materialization_guard.take() {
3669 guard.retain();
3670 }
3671 }
3672
3673 fn retain_launch_guards(&mut self) {
3675 if let Some(overlay) = self.harness_mcp_overlay.take() {
3676 overlay.retain();
3677 }
3678 #[cfg(feature = "dig2-station-probe")]
3679 if let Some(guard) = self.browser_station_lease_guard.take() {
3680 guard.retain();
3681 }
3682 }
3683}
3684
3685trait SpawnPreparer {
3689 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3690 fn select_environment(
3692 &self,
3693 instance_id: AgentInstanceId,
3694 provider: &AgentId,
3695 mode: SessionMode,
3696 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3697 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3698 fn install_launch_overlay(
3700 &self,
3701 instance_id: AgentInstanceId,
3702 overlay: PreparedNativeLaunchOverlay,
3703 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3704}
3705
3706impl NodeShared {
3707 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3710 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3711 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3712 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3713 if let Some(file) = self.session_resume_journal() {
3714 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3715 }
3716 key
3717 }
3718
3719 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3720 let accounts = self.m4a_accounts.as_ref()?;
3721 let nick = self.m4a_nick_of(instance_id)?;
3722 match accounts.session_workdir(root, provider.as_str(), &nick) {
3723 Ok(dir) => dir,
3724 Err(error) => {
3725 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3726 None
3727 }
3728 }
3729 }
3730
3731 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3732 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3733 }
3734
3735 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3738 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3739 for record in records.records.values_mut() {
3740 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3741 record.display_name = nick.to_owned();
3742 }
3743 }
3744 }
3745
3746 fn m4a_session_overlay(
3749 &self,
3750 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3751 m4a_provider: &str,
3752 provider: &AgentId,
3753 instance_id: AgentInstanceId,
3754 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3755 session_key: &str,
3756 mode: SessionMode,
3757 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3758 let session_key = session_key.to_owned();
3759 if mode == SessionMode::Acp {
3760 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3761 }
3762 Ok( match accounts.provision(m4a_provider, &session_key) {
3763 Ok(account) => {
3764 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3765 let mut identity = account.env_mutations();
3766 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3767 let merged = match environment_overlay {
3768 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3769 .map(PreparedNativeLaunchOverlay::Instance),
3770 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3771 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3772 }
3773 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3774 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3775 }
3776 }
3777 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3778 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3779 let mut merged = merged;
3780 match accounts.write_session_file(&account) {
3781 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3782 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3783 Ok(args) if !args.is_empty() => {
3784 merged = match merged {
3785 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3786 .append_extra_args(args)
3787 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3788 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3789 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3790 .into_instance_with_extra_args(args)
3791 .map(PreparedNativeLaunchOverlay::Instance)
3792 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3793 };
3794 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3795 }
3796 Ok(_) => {}
3797 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3798 },
3799 }
3800 self.m4a_live
3801 .lock()
3802 .unwrap_or_else(|p| p.into_inner())
3803 .insert(account.nick.clone(), (instance_id, provider.clone()));
3804 self.name_record_after_nick(instance_id, &account.nick);
3805 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3806 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3808 Some(merged)
3809 }
3810 Err(error) => {
3811 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3812 environment_overlay
3813 }
3814 })
3815 }
3816}
3817
3818impl NodeShared {
3819 fn m4a_acp_mcp_overlay(
3822 &self,
3823 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3824 m4a_provider: &str,
3825 provider: &AgentId,
3826 instance_id: AgentInstanceId,
3827 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3828 session_key: &str,
3829 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3830 let Some(control) = self.native_launch_profile_control.as_ref() else {
3831 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3832 return Ok(environment_overlay);
3833 };
3834 let account = match accounts.provision(m4a_provider, session_key) {
3835 Ok(a) => a,
3836 Err(error) => {
3837 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3838 return Ok(environment_overlay);
3839 }
3840 };
3841 let session_file = match accounts.write_session_file(&account) {
3842 Ok(p) => p,
3843 Err(error) => {
3844 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3845 return Ok(environment_overlay);
3846 }
3847 };
3848 let env_entries: Vec<(OsString, OsString)> = account
3849 .env_mutations()
3850 .into_iter()
3851 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3852 .filter_map(|m| m.value.map(|v| (m.key, v)))
3853 .collect();
3854 let args: Vec<OsString> = vec![
3855 "--session-file".into(),
3856 session_file.into_os_string(),
3857 "mcp".into(),
3858 "--provider".into(),
3859 provider.as_str().into(),
3860 ];
3861 let overlay = NativeMcpServerLaunchOverlay::new(
3862 provider.clone(),
3863 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3864 accounts.bin().as_os_str().to_os_string(),
3865 args,
3866 env_entries,
3867 Vec::new(),
3868 );
3869 match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3870 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3871 Err(error) => {
3872 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3873 return Ok(environment_overlay);
3874 }
3875 }
3876 self.m4a_live
3877 .lock()
3878 .unwrap_or_else(|p| p.into_inner())
3879 .insert(account.nick.clone(), (instance_id, provider.clone()));
3880 self.name_record_after_nick(instance_id, &account.nick);
3881 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3882 Ok(environment_overlay)
3883 }
3884}
3885
3886impl SpawnPreparer for NodeShared {
3887 fn select_environment(
3888 &self,
3889 instance_id: AgentInstanceId,
3890 provider: &AgentId,
3891 mode: SessionMode,
3892 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3893 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3894 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3895 }
3896
3897 fn install_launch_overlay(
3898 &self,
3899 instance_id: AgentInstanceId,
3900 overlay: PreparedNativeLaunchOverlay,
3901 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3902 self.install_prepared_launch_overlay(instance_id, overlay)
3903 }
3904
3905 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3906 let SpawnPrepareRequest {
3907 instance_id,
3908 address,
3909 provider,
3910 mode,
3911 environment_profile,
3912 bundle,
3913 context,
3914 managed_authority,
3915 harness_mcp,
3916 approval_level,
3917 network_allowlist,
3918 browser_profile_id,
3919 } = request;
3920 #[cfg(feature = "dig2-station-probe")]
3925 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3926 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3927 match acquire_lease(
3928 &self.browser_station_leases,
3929 profile_id,
3930 instance_id,
3931 unix_time_ms(),
3932 ) {
3933 Ok(guard) => Some(guard),
3934 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3935 return Err(failure(
3936 NodeFailureCode::BrowserStationProfileBusy,
3937 "dig2browser station profile is already leased by another session",
3938 ));
3939 }
3940 Err(BrowserStationLeaseError::CapacityExceeded) => {
3941 return Err(failure(
3942 NodeFailureCode::BrowserStationProfileBusy,
3943 "dig2browser station profile lease capacity exhausted",
3944 ));
3945 }
3946 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3947 return Err(failure(
3948 NodeFailureCode::BrowserStationProfileBusy,
3949 "session already holds a dig2browser station profile lease",
3950 ));
3951 }
3952 }
3953 } else {
3954 None
3955 };
3956 #[cfg(not(feature = "dig2-station-probe"))]
3957 let _ = browser_profile_id;
3958 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3959 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3968 {
3969 return Err(failure(
3970 NodeFailureCode::BindingMismatch,
3971 "harness MCP reservation does not match the exact PTY/ACP provider",
3972 ));
3973 }
3974 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3975 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3976 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3977 })?;
3978 let launch = prepared.launch();
3983 let mut env_entries = vec![
3984 (
3985 OsString::from(launch.endpoint_env.as_str()),
3986 prepared.endpoint().as_os_str().to_os_string(),
3987 ),
3988 (
3989 OsString::from(launch.token_env.as_str()),
3990 prepared.token().expose().into(),
3991 ),
3992 (
3993 OsString::from(launch.program_env.as_str()),
3994 prepared.helper_program().as_os_str().to_os_string(),
3995 ),
3996 ];
3997 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3998 env_entries.push(trace_entry);
3999 }
4000 if let Some(door) = crate::door::door_listen_addr() {
4001 env_entries.push((
4002 OsString::from("HATCHERY_NODE_DOOR"),
4003 OsString::from(door.to_string()),
4004 ));
4005 let session_key = format!(
4009 "pty-{}-{}-{}",
4010 self.incarnation_id,
4011 address.workspace_id.as_str(),
4012 address.session.instance_id.0
4013 );
4014 env_entries.push((
4015 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4016 OsString::from(session_key),
4017 ));
4018 }
4019 let overlay = NativeMcpServerLaunchOverlay::new(
4020 provider.clone(),
4021 launch.server_name.as_str(),
4022 prepared.helper_program().as_os_str().to_os_string(),
4023 launch.args.iter().map(OsString::from).collect(),
4024 env_entries,
4025 launch.scrub_env.iter().map(OsString::from).collect(),
4026 ).map_err(|_| failure(
4027 NodeFailureCode::HarnessMcpUnavailable,
4028 "harness MCP launch overlay is unavailable",
4029 ))?;
4030 control
4031 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4032 .map_err(|_| failure(
4033 NodeFailureCode::HarnessMcpUnavailable,
4034 "harness MCP launch overlay could not be installed",
4035 ))?;
4036 tracing::info!(
4042 node_id = %self.node_id,
4043 instance_id = ?instance_id,
4044 provider = %provider,
4045 mode = ?mode,
4046 "harness MCP overlay installed for spawn",
4047 );
4048 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4049 } else {
4050 None
4051 };
4052 let prepared_materialization = self.prepare_session_materialization(
4053 &address,
4054 &provider,
4055 mode,
4056 environment_profile,
4057 bundle,
4058 context,
4059 managed_authority.cloned(),
4060 )?;
4061 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4062 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4063 None => (None, None),
4064 };
4065 let materialization_id = materialization_guard
4066 .as_ref()
4067 .and_then(SessionMaterializationGuard::id)
4068 .cloned();
4069 let mut environment_selection = SpawnPreparer::select_environment(self,
4070 instance_id,
4071 &provider,
4072 mode,
4073 environment_profile,
4074 )?;
4075 let provider_home = materialization_guard
4078 .as_ref()
4079 .and_then(SessionMaterializationGuard::provider_home);
4080 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4081 &provider,
4082 mode,
4083 approval_level,
4084 network_allowlist,
4085 provider_home,
4086 )?;
4087 let mut network_overlay_args = network_overlay_args;
4088 {
4090 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4091 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4092 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4093 homes.extend(std::env::split_paths(&extra));
4094 }
4095 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4096 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4097 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4098 }
4099 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4100 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4101 }
4102 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::journalled(provider.as_str(), mode == SessionMode::Acp) {
4104 if let Some(file) = self.session_resume_journal() {
4105 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4106 instance_id: instance_id.0,
4107 provider: provider.as_str().to_owned(),
4108 workspace_id: address.workspace_id.as_str().to_owned(),
4109 provider_session_id: String::new(),
4110 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4111 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4112 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4113 cwd: String::new(),
4114 acp: mode == SessionMode::Acp,
4115 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4116 cols: 0,
4117 rows: 0,
4118 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4119 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4120 });
4121 if let Some(dir) = file.parent() {
4122 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4123 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4124 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4125 provider: Some(provider.as_str().to_owned()),
4126 ..crate::session_meta::Meta::default()
4127 });
4128 crate::session_meta::revive(dir, instance_id.0);
4129 }
4130 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4131 }
4132 }
4133 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4134 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4135 }
4136 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4137 (prepared, args) if args.is_empty() => prepared,
4138 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4139 overlay.append_extra_args(args).map_err(|_| {
4140 failure(
4141 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4142 "network allowlist overlay could not merge with instance launch args",
4143 )
4144 })?;
4145 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4146 }
4147 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4148 Some(PreparedNativeLaunchOverlay::Instance(
4149 overlay
4150 .into_instance_with_extra_args(args)
4151 .map_err(|_| {
4152 failure(
4153 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154 "network allowlist overlay could not merge with environment overlay",
4155 )
4156 })?,
4157 ))
4158 }
4159 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4160 NativeInstanceLaunchOverlay::new(
4161 provider.clone(),
4162 match mode {
4163 SessionMode::Pty => TransportKind::Pty,
4164 SessionMode::Acp => TransportKind::Acp,
4165 SessionMode::Inline => TransportKind::Pipe,
4166 },
4167 Vec::new(),
4168 args,
4169 )
4170 .map_err(|_| {
4171 failure(
4172 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4173 "network allowlist overlay is invalid for this transport",
4174 )
4175 })?,
4176 )),
4177 };
4178 #[cfg(unix)]
4181 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4183 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4184 });
4185 #[cfg(unix)]
4186 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4189 #[cfg(unix)]
4190 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4191 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4192 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4193 let uid = unsafe { libc_geteuid() };
4194 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4195 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4196 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4197 Some(vec![
4198 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4199 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4200 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4201 ])
4202 }
4203 _ => None,
4204 };
4205 #[cfg(not(unix))]
4206 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4207 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4208 (SessionMode::Pty, _, _, Some(identity)) => {
4209 let merged = match environment_overlay {
4210 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4211 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4212 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4213 }
4214 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4215 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4216 }
4217 }
4218 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4219 Some(merged)
4220 }
4221 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4222 let session_key = self.m4a_session_key(instance_id);
4223 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4224 }
4225 (SessionMode::Pty, Some(_), None, _) => {
4226 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4227 environment_overlay
4228 }
4229 _ => environment_overlay,
4230 };
4231 let mut instance_overlay = environment_overlay
4232 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4233 .transpose()?
4234 .flatten();
4235 Ok(PreparedSpawn {
4236 instance_overlay,
4237 environment_selection,
4238 materialization_guard,
4239 harness_mcp_overlay,
4240 #[cfg(feature = "dig2-station-probe")]
4241 browser_station_lease_guard,
4242 materialization_id,
4243 })
4244 }
4245}
4246
4247struct SessionRecords {
4248 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4249}
4250
4251struct SpawnIdempotencyCache {
4252 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4253}
4254
4255struct SpawnIdempotencyEntry {
4256 value: SpawnIdempotencyValue,
4257 expires_at: Instant,
4258}
4259
4260enum SpawnIdempotencyValue {
4261 Standard {
4262 spec: SpawnSpec,
4263 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4264 },
4265 Managed {
4266 request: ManagedWorktreeSpawnRequest,
4267 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4268 },
4269 HarnessMcp,
4270}
4271
4272enum DurableManagedSpawnReplayDecision {
4273 Reserved,
4274 PendingLinked,
4275 Committed(ManagedWorktreeSpawnReceipt),
4276}
4277
4278impl SpawnIdempotencyValue {
4279 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4280 match self {
4281 Self::Standard { result, .. } => result
4282 .as_ref()
4283 .ok()
4284 .and_then(|receipt| receipt.context.as_ref()),
4285 Self::Managed { result, .. } => result
4286 .as_ref()
4287 .ok()
4288 .and_then(|receipt| receipt.spawn.context.as_ref()),
4289 Self::HarnessMcp => None,
4290 }
4291 .is_some_and(|receipt| &receipt.id == context_id)
4292 }
4293}
4294
4295#[derive(Clone, Copy)]
4296struct ControllerLease {
4297 connection_id: u64,
4298 expires_at: Instant,
4299}
4300
4301struct NodeEventHistory {
4302 last_sequence: u64,
4303 replay_floor_sequence: u64,
4304 events: VecDeque<NodeEventEnvelope>,
4305 record_providers: BTreeMap<SessionRecordId, AgentId>,
4306 removed_record_providers: BTreeMap<u64, AgentId>,
4307}
4308
4309impl NodeEventHistory {
4310 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4311 Self {
4312 last_sequence: 0,
4313 replay_floor_sequence: 1,
4314 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4315 record_providers,
4316 removed_record_providers: BTreeMap::new(),
4317 }
4318 }
4319}
4320
4321impl NodeShared {
4322 fn session_resume_journal(&self) -> Option<PathBuf> {
4326 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4327 }
4328
4329 async fn restore_journalled_sessions(&self) {
4332 let Some(file) = self.session_resume_journal() else { return };
4333 if !crate::pty_resume::enabled() { return; }
4334 for e in crate::pty_resume::load(&file) {
4335 crate::pty_resume::remove(&file, e.instance_id);
4336 if !e.session_key.is_empty() {
4338 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4339 }
4340 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4341 if e.acp {
4342 let record_id = {
4343 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4344 records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4345 };
4346 let cur = self.next_instance_id.load(Ordering::Acquire);
4347 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4348 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4349 let resumed = match record_id {
4350 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4351 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4352 };
4353 let r = match resumed {
4355 Ok(a) => Ok(a),
4356 Err(err) => {
4357 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4358 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4359 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4360 }
4361 };
4362 match r {
4363 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4364 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4365 }
4366 continue;
4367 }
4368 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4369 let cur = self.next_instance_id.load(Ordering::Acquire);
4371 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4372 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4373 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4374 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4375 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4376 }
4377 continue;
4378 }
4379 let folder = std::path::Path::new(&e.cwd);
4380 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4381 let name = match (in_session_dir, folder.file_name()) {
4382 (true, Some(n)) => n.to_string_lossy().into_owned(),
4383 _ => format!("{} #{}", e.provider, e.instance_id),
4384 };
4385 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4386 let record = match self.index_provider_session(ws, provider, identity, name) {
4387 Ok(r) => r,
4388 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4389 };
4390 let cur = self.next_instance_id.load(Ordering::Acquire);
4391 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4392 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4393 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4394 match r {
4395 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4396 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4397 }
4398 }
4399 }
4400
4401 fn refresh_session_journal(&self) {
4402 let Some(file) = self.session_resume_journal() else { return };
4403 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4404 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4405 crate::pty_resume::refresh(&file, &homes);
4406 let ids: Vec<(u64, String)> = {
4407 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4408 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4409 };
4410 for (inst, sid) in ids {
4411 crate::pty_resume::set_session_id(&file, inst, &sid);
4412 }
4413 }
4414
4415 fn session_grok_leader_socket(
4416 &self,
4417 provider: &AgentId,
4418 mode: SessionMode,
4419 instance_id: AgentInstanceId,
4420 ) -> Option<std::path::PathBuf> {
4421 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4422 return None;
4423 }
4424 let root = self
4425 .m4a_accounts
4426 .as_ref()
4427 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4428 .unwrap_or_else(std::env::temp_dir);
4429 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4430 if let Some(previous) = self
4431 .grok_leaders
4432 .lock()
4433 .unwrap_or_else(|p| p.into_inner())
4434 .insert(instance_id, socket.clone())
4435 {
4436 crate::grok_leader::retire(&previous);
4437 }
4438 Some(socket)
4439 }
4440
4441 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4443 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4444 if let Some(socket) = socket {
4445 crate::grok_leader::retire(&socket);
4446 }
4447 }
4448
4449 fn new_with_incarnation(
4450 handle: Gate4AgentHandle,
4451 access_token: String,
4452 node_id: NodeId,
4453 incarnation_id: NodeIncarnationId,
4454 workspaces: Vec<WorkspaceConfig>,
4455 enabled_providers: Vec<AgentId>,
4456 provider_runtime_statuses: ProviderRuntimeStatuses,
4457 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4458 provider_contracts: Vec<ProviderContractSupport>,
4459 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4460 spawn_profiles: SpawnProfileRegistry,
4461 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4462 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4463 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4464 state_path: Option<PathBuf>,
4465 records: Vec<ManagedSessionRecord>,
4466 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4467 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4468 materializations: Vec<MaterializationOwnershipRecord>,
4469 persistence_warning: Option<String>,
4470 input_settle_timeout_ms: u64,
4471 ) -> Self {
4472 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4473 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4474 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4475 let (harness_mcp_proxy_event_tx, _) =
4476 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4477 let record_providers = records
4478 .iter()
4479 .map(|record| (record.record_id.clone(), record.provider.clone()))
4480 .collect();
4481 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4482 .iter()
4483 .map(|workspace| {
4484 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4485 })
4486 .collect();
4487 for lease in &managed_worktrees {
4488 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4489 }
4490 let managed_worktree_profiles = workspaces.iter()
4491 .map(|workspace| (
4492 workspace.workspace_id.clone(),
4493 workspace.managed_worktree_profiles.clone(),
4494 ))
4495 .collect();
4496 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4497 managed_worktrees,
4498 managed_worktree_tombstones,
4499 ).expect("durable managed worktree registry was validated while loading");
4500 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4501 managed_worktrees.reattach_record_holders(
4502 &managed_worktree_record_holders(&records),
4503 unix_time_ms(),
4504 );
4505 Self {
4506 handle,
4507 access_token,
4508 node_id,
4509 incarnation_id,
4510 started_at_unix_ms: std::time::SystemTime::now()
4511 .duration_since(std::time::UNIX_EPOCH)
4512 .unwrap_or_default()
4513 .as_millis()
4514 .min(u64::MAX as u128) as u64,
4515 workspaces: RwLock::new(
4516 workspaces
4517 .into_iter()
4518 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4519 .collect(),
4520 ),
4521 worktree_service_modes,
4522 managed_worktree_profiles,
4523 managed_worktrees: Mutex::new(managed_worktrees),
4524 enabled_providers,
4525 provider_runtime_statuses,
4526 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4527 provider_runtime_monitor,
4528 provider_contracts,
4529 provider_adapter_contracts,
4530 spawn_profiles,
4531 environment_profiles: RwLock::new(BTreeMap::new()),
4532 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4533 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4534 #[cfg(feature = "dig2-station-probe")]
4535 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4536 bundle_catalog: RwLock::new(BundleCatalog::default()),
4537 delivery_store: Mutex::new(None),
4538 context_pack_store: Mutex::new(None),
4539 context_catalog: RwLock::new(ContextPackCatalog::default()),
4540 native_launch_profile_control,
4541 harness_mcp_registry: None,
4542 native_session_catalog,
4543 session_environment_materializer,
4544 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4545 m4a_live: Mutex::new(BTreeMap::new()),
4546 m4a_keys: Mutex::new(BTreeMap::new()),
4547 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4548 grok_leaders: Mutex::new(BTreeMap::new()),
4549 #[cfg(unix)]
4550 #[cfg(unix)]
4551 signer_proxy: std::sync::OnceLock::new(),
4552 materializations: Mutex::new(
4553 materializations
4554 .into_iter()
4555 .map(|record| (record.id().clone(), record))
4556 .collect(),
4557 ),
4558 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4559 entries: BTreeMap::new(),
4560 }),
4561 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4562 controller: Mutex::new(None),
4563 provisional_spawns: Mutex::new(BTreeMap::new()),
4564 pending_acp_options: Mutex::new(BTreeMap::new()),
4565 history: Mutex::new(NodeEventHistory::new(record_providers)),
4566 event_tx,
4567 terminal_event_tx,
4568 agent_stream_event_tx,
4569 harness_mcp_proxy_event_tx,
4570 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4571 pty_input_wake: tokio::sync::Notify::new(),
4572 pty_input_marks: Mutex::new(BTreeMap::new()),
4573 provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4574 next_connection_id: AtomicU64::new(1),
4575 next_instance_id: AtomicU64::new(1),
4576 next_command_id: AtomicU64::new(1),
4577 shutdown: AtomicBool::new(false),
4578 shutdown_notify: Notify::new(),
4579 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4580 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4581 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4582 mutation_gate: AsyncMutex::new(()),
4583 session_bindings: Mutex::new(BTreeMap::new()),
4584 session_records: Mutex::new(SessionRecords {
4585 records: records
4586 .into_iter()
4587 .map(|record| (record.record_id.clone(), record))
4588 .collect(),
4589 }),
4590 state_path,
4591 persistence_error: RwLock::new(
4592 persistence_warning
4593 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4594 ),
4595 state_transaction: Mutex::new(()),
4596 input_settle_timeout_ms,
4597 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4598 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4599 shell_efficiency_profile: OnceLock::new(),
4600 native_session_gauge: AtomicUsize::new(0),
4601 connection_loop_iterations: AtomicU64::new(0),
4602 connection_events_sent: AtomicU64::new(0),
4603 rejected_commands_total: AtomicU64::new(0),
4604 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4605 drive_loop_iterations_total: AtomicU64::new(0),
4606 drive_loop_iterations_idle: AtomicU64::new(0),
4607 session_record_retention: SessionRecordRetentionConfig::default(),
4608 retired_records_total: AtomicU64::new(0),
4609 #[cfg(feature = "fixture")]
4610 fixture_semantic_hook_policy: false,
4611 #[cfg(feature = "fixture")]
4612 fixture_spawn_managed_worktree_v2_failure_probe:
4613 SpawnManagedWorktreeV2FailureProbe::default(),
4614 }
4615 }
4616
4617 #[cfg(test)]
4618 fn new(
4619 handle: Gate4AgentHandle,
4620 access_token: String,
4621 node_id: NodeId,
4622 workspaces: Vec<WorkspaceConfig>,
4623 enabled_providers: Vec<AgentId>,
4624 ) -> Self {
4625 Self::new_with_incarnation(
4626 handle,
4627 access_token,
4628 node_id,
4629 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4630 workspaces,
4631 enabled_providers,
4632 ProviderRuntimeStatuses::default(),
4633 None,
4634 Vec::new(),
4635 Vec::new(),
4636 SpawnProfileRegistry::default(),
4637 None,
4638 None,
4639 None,
4640 None,
4641 Vec::new(),
4642 Vec::new(),
4643 Vec::new(),
4644 Vec::new(),
4645 None,
4646 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4647 )
4648 }
4649
4650 fn persistence_error(&self) -> Option<String> {
4651 self.persistence_error
4652 .read()
4653 .unwrap_or_else(|poisoned| poisoned.into_inner())
4654 .clone()
4655 }
4656
4657 async fn admit_provider_runtime(
4658 &self,
4659 provider: &AgentId,
4660 requirement: ProviderRuntimeRequirement,
4661 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4662 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4663 let pipe_transport = monitor.supports_pipe_transport(provider);
4664 let acp_transport = monitor.supports_acp_transport(provider);
4665 let provider = provider.clone();
4666 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4667 let (status, admission) = timeout(
4668 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4669 refresh,
4670 )
4671 .await
4672 .map_err(|_| failure(
4673 NodeFailureCode::BackendBusy,
4674 "provider runtime probe exceeded its bounded deadline",
4675 ))?
4676 .map_err(|_| failure(
4677 NodeFailureCode::BackendOperationFailed,
4678 "provider runtime probe task failed",
4679 ))?;
4680 if let Some(status) = status {
4681 self
4682 .provider_runtime_status_updates
4683 .write()
4684 .unwrap_or_else(|poisoned| poisoned.into_inner())
4685 .insert(status.provider().clone(), status);
4686 }
4687 admission.and_then(|policy| {
4688 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4689 })
4690 } else {
4691 crate::provider_runtime::admit_status(
4697 &self.provider_runtime_statuses,
4698 provider,
4699 false,
4700 false,
4701 requirement,
4702 )
4703 };
4704 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4705 admission.map_err(|error| match error {
4706 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4707 NodeFailureCode::BackendOperationFailed,
4708 "provider launcher is unavailable",
4709 ),
4710 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4711 NodeFailureCode::UnsupportedCapability,
4712 "provider semantic capability is not verified",
4713 ),
4714 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4715 NodeFailureCode::BackendBusy,
4716 "provider runtime probe is already in progress",
4717 ),
4718 })
4719 }
4720
4721 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4727 match self.provider_runtime_monitor.as_deref() {
4728 Some(monitor) => monitor.supports_pipe_transport(provider),
4729 None => false,
4730 }
4731 }
4732
4733 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4739 match self.provider_runtime_monitor.as_deref() {
4740 Some(monitor) => monitor.supports_acp_transport(provider),
4741 None => false,
4742 }
4743 }
4744
4745 fn admit_declared_hook_semantics(
4761 &self,
4762 provider: &AgentId,
4763 policy: ProviderRuntimePolicy,
4764 ) -> ProviderRuntimePolicy {
4765 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4766 return policy;
4767 }
4768 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4769 contract.provider == *provider && contract.family == AdapterFamily::Hook
4770 });
4771 if !declares_hook {
4772 return policy;
4773 }
4774 ProviderRuntimePolicy::new(
4775 policy.raw_pty_lifecycle,
4776 policy.semantic_readiness,
4777 policy.structured_prompt,
4778 policy.provider_session_identity,
4779 policy.semantic_resume,
4780 true,
4781 )
4782 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4783 }
4784
4785 fn resolve_network_allowlist_overlay_args(
4800 &self,
4801 provider: &AgentId,
4802 mode: SessionMode,
4803 approval_level: ApprovalLevel,
4804 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4805 provider_home: Option<&Path>,
4806 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4807 let Some(allowlist_id) = network_allowlist else {
4808 return Ok(Vec::new());
4809 };
4810 let catalog = self
4811 .network_allowlist_catalog
4812 .read()
4813 .unwrap_or_else(|poisoned| poisoned.into_inner());
4814 let Some(entry) = catalog.get(allowlist_id) else {
4815 return Err(failure(
4816 NodeFailureCode::UnknownNetworkAllowlist,
4817 "spawn network allowlist is unavailable on this node",
4818 ));
4819 };
4820
4821 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4823 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4824 |message| {
4825 failure(
4826 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4827 &message,
4828 )
4829 },
4830 )?;
4831 if !matches!(mode, SessionMode::Pty) {
4832 return Err(failure(
4833 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4834 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4835 ));
4836 }
4837 let Some(home) = provider_home else {
4838 return Err(failure(
4839 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4841 ));
4842 };
4843 let domains =
4844 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4845 if domains.is_empty() {
4846 return Err(failure(
4847 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4848 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4849 ));
4850 }
4851 let settings_path = home.join(
4852 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4853 );
4854 let json =
4855 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4856 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4857 |_| {
4858 failure(
4859 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860 "Claude station network settings sidecar could not be written under ProviderHome",
4861 )
4862 },
4863 )?;
4864 let overlay =
4865 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4866 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4867 }
4868
4869 let Some(native) = entry.provider_native.as_ref() else {
4870 return Ok(Vec::new());
4871 };
4872 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4873 provider.as_str(),
4874 approval_level,
4875 native,
4876 )
4877 .map_err(|message| {
4878 failure(
4879 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4880 &message,
4881 )
4882 })?;
4883 if overlay.is_empty() {
4884 return Ok(Vec::new());
4885 }
4886 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4887 return Err(failure(
4888 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4889 "Codex network_access -c overlay requires PTY or ACP transport",
4890 ));
4891 }
4892 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4893 }
4894
4895 fn resolve_codex_network_access_overlay_args(
4897 &self,
4898 provider: &AgentId,
4899 mode: SessionMode,
4900 approval_level: ApprovalLevel,
4901 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4902 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4903 self.resolve_network_allowlist_overlay_args(
4904 provider,
4905 mode,
4906 approval_level,
4907 network_allowlist,
4908 None,
4909 )
4910 }
4911
4912 fn resolve_environment_profile(
4913 &self,
4914 resolved: &ResolvedSpawnSpec,
4915 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4916 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4922 let catalog = self
4923 .network_allowlist_catalog
4924 .read()
4925 .unwrap_or_else(|poisoned| poisoned.into_inner());
4926 let Some(entry) = catalog.get(allowlist_id) else {
4927 return Err(failure(
4928 NodeFailureCode::UnknownNetworkAllowlist,
4929 "spawn network allowlist is unavailable on this node",
4930 ));
4931 };
4932 if let Some(native) = entry.provider_native.as_ref() {
4935 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4936 resolved.provider.as_str(),
4937 resolved.approval_level,
4938 native,
4939 ) {
4940 return Err(failure(
4941 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4942 &message,
4943 ));
4944 }
4945 }
4946 }
4947 #[cfg(feature = "dig2-station-probe")]
4956 if resolved.browser_profile_id.is_some() {
4957 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4958 match probe_station_reachable() {
4959 Ok(()) => {}
4960 Err(StationProbeError::UnsupportedPlatform)
4961 | Err(StationProbeError::InvalidPipeSuffix) => {
4962 return Err(failure(
4963 NodeFailureCode::BrowserStationProbeUnavailable,
4964 "dig2browser station probe unavailable on this platform",
4965 ));
4966 }
4967 Err(StationProbeError::Unreachable) => {
4968 return Err(failure(
4969 NodeFailureCode::BrowserStationUnreachable,
4970 "dig2browser station named-pipe is unreachable",
4971 ));
4972 }
4973 }
4974 }
4975 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4976 return Ok(None);
4977 };
4978 let profiles = self
4979 .environment_profiles
4980 .read()
4981 .unwrap_or_else(|poisoned| poisoned.into_inner());
4982 let binding = profiles.get(profile_id).ok_or_else(|| {
4983 failure(
4984 NodeFailureCode::UnknownEnvironmentProfile,
4985 "spawn environment profile is unavailable on this node",
4986 )
4987 })?;
4988 if binding.provider != resolved.provider
4989 || binding.native_profile_id(resolved.mode).is_none()
4990 {
4991 return Err(failure(
4992 NodeFailureCode::EnvironmentProfileBindingMismatch,
4993 "spawn environment profile does not match the provider and mode",
4994 ));
4995 }
4996 Ok(Some(ResolvedEnvironmentProfileReceipt {
5000 profile_id: binding.id.clone(),
5001 profile_revision: binding.revision.clone(),
5002 network_allowlist: resolved.network_allowlist.clone(),
5003 browser_profile_id: resolved.browser_profile_id.clone(),
5004 }))
5005 }
5006
5007 fn resolve_bundle(
5008 &self,
5009 resolved: &ResolvedSpawnSpec,
5010 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5011 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5012 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5013 return Ok(None);
5014 };
5015 let catalog = self.bundle_catalog
5016 .read()
5017 .unwrap_or_else(|poisoned| poisoned.into_inner());
5018 let bundle = catalog.get(bundle_id).ok_or_else(|| {
5019 failure(
5020 NodeFailureCode::UnknownBundle,
5021 "spawn bundle is unavailable on this node",
5022 )
5023 })?;
5024 self.bundle_layout(
5025 &resolved.provider,
5026 resolved.mode,
5027 environment_profile,
5028 bundle,
5029 )?;
5030 Ok(Some(bundle.receipt()))
5031 }
5032
5033 fn begin_delivery_stage(
5034 &self,
5035 manifest: DeliveryBundleManifestV2,
5036 ) -> Result<NodeResponse, NodeFailure> {
5037 let mut store = self
5038 .delivery_store
5039 .lock()
5040 .unwrap_or_else(|poisoned| poisoned.into_inner());
5041 let store = store.as_mut().ok_or_else(|| {
5042 failure(
5043 NodeFailureCode::DeliveryStageStorageFailed,
5044 "node delivery store is unavailable",
5045 )
5046 })?;
5047 let begun = store.begin(manifest).map_err(delivery_failure)?;
5048 Ok(NodeResponse::DeliveryStageBegun {
5049 stage_id: begun.stage_id,
5050 manifest_digest: begun.manifest_digest,
5051 missing_blobs: begun.missing_blobs,
5052 })
5053 }
5054
5055 fn put_delivery_blob_chunk(
5056 &self,
5057 stage_id: DeliveryStageId,
5058 blob_digest: DeliveryBlobDigestV1,
5059 offset: u64,
5060 chunk_hex: DeliveryBlobChunkHexV1,
5061 ) -> Result<NodeResponse, NodeFailure> {
5062 let mut store = self
5063 .delivery_store
5064 .lock()
5065 .unwrap_or_else(|poisoned| poisoned.into_inner());
5066 let store = store.as_mut().ok_or_else(|| {
5067 failure(
5068 NodeFailureCode::DeliveryStageStorageFailed,
5069 "node delivery store is unavailable",
5070 )
5071 })?;
5072 let accepted = store
5073 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5074 .map_err(delivery_failure)?;
5075 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5076 stage_id: accepted.stage_id,
5077 blob_digest: accepted.blob_digest,
5078 next_offset: accepted.next_offset,
5079 })
5080 }
5081
5082 fn commit_delivery_stage(
5083 &self,
5084 stage_id: DeliveryStageId,
5085 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5086 let mut store = self
5087 .delivery_store
5088 .lock()
5089 .unwrap_or_else(|poisoned| poisoned.into_inner());
5090 let store = store.as_mut().ok_or_else(|| {
5091 failure(
5092 NodeFailureCode::DeliveryStageStorageFailed,
5093 "node delivery store is unavailable",
5094 )
5095 })?;
5096 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5097 let mut catalog = self
5098 .bundle_catalog
5099 .write()
5100 .unwrap_or_else(|poisoned| poisoned.into_inner());
5101 let mut next_catalog = catalog.clone();
5102 next_catalog
5103 .insert_idempotent(prepared.bundle().clone())
5104 .map_err(|_| {
5105 failure(
5106 NodeFailureCode::DeliveryStageConflict,
5107 "delivery bundle identity conflicts with installed content",
5108 )
5109 })?;
5110 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5111 *catalog = next_catalog;
5112 Ok(receipt)
5113 }
5114
5115 fn abort_delivery_stage(
5116 &self,
5117 stage_id: &DeliveryStageId,
5118 ) -> Result<(), NodeFailure> {
5119 let mut store = self
5120 .delivery_store
5121 .lock()
5122 .unwrap_or_else(|poisoned| poisoned.into_inner());
5123 let store = store.as_mut().ok_or_else(|| {
5124 failure(
5125 NodeFailureCode::DeliveryStageStorageFailed,
5126 "node delivery store is unavailable",
5127 )
5128 })?;
5129 store.abort(stage_id).map_err(delivery_failure)
5130 }
5131
5132 fn resolve_context(
5133 &self,
5134 resolved: &ResolvedSpawnSpec,
5135 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5136 let Some(context_id) = resolved.context_id.as_ref() else {
5137 return Ok(None);
5138 };
5139 self.context_catalog
5140 .read()
5141 .unwrap_or_else(|poisoned| poisoned.into_inner())
5142 .get(context_id)
5143 .map(|context| context.receipt().clone())
5144 .ok_or_else(|| {
5145 failure(
5146 NodeFailureCode::UnknownContextPack,
5147 "spawn context pack is unavailable on this node",
5148 )
5149 })
5150 .map(Some)
5151 }
5152
5153 fn bundle_layout(
5154 &self,
5155 provider: &AgentId,
5156 mode: SessionMode,
5157 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5158 bundle: &NodeBundle,
5159 ) -> Result<BundleProviderLayout, NodeFailure> {
5160 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5161 failure(
5162 NodeFailureCode::BundleBindingMismatch,
5163 "spawn bundle does not match the provider and mode",
5164 )
5165 })?;
5166 if layout != BundleProviderLayout::Codex {
5167 return Ok(layout);
5168 }
5169 let profile = environment_profile.and_then(|receipt| {
5170 let profiles = self
5171 .environment_profiles
5172 .read()
5173 .unwrap_or_else(|poisoned| poisoned.into_inner());
5174 let binding = profiles.get(&receipt.profile_id)?;
5175 if binding.revision != receipt.profile_revision
5176 || &binding.provider != provider
5177 || binding.native_profile_id(mode).is_none()
5178 {
5179 return None;
5180 }
5181 self.environment_materialization_profiles
5182 .read()
5183 .unwrap_or_else(|poisoned| poisoned.into_inner())
5184 .get(&receipt.profile_id)
5185 .cloned()
5186 });
5187 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5188 return Err(failure(
5189 NodeFailureCode::BundleBindingMismatch,
5190 "Codex bundle requires an exact isolated CODEX_HOME profile",
5191 ));
5192 }
5193 Ok(layout)
5194 }
5195
5196 fn select_environment_profile(
5197 &self,
5198 instance_id: AgentInstanceId,
5199 provider: &AgentId,
5200 mode: SessionMode,
5201 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5202 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5203 let Some(environment_profile) = environment_profile else {
5204 return Ok(None);
5205 };
5206 let native_profile_id = {
5207 let profiles = self
5208 .environment_profiles
5209 .read()
5210 .unwrap_or_else(|poisoned| poisoned.into_inner());
5211 let binding = profiles
5212 .get(&environment_profile.profile_id)
5213 .ok_or_else(|| {
5214 failure(
5215 NodeFailureCode::UnknownEnvironmentProfile,
5216 "session environment profile is unavailable on this node",
5217 )
5218 })?;
5219 if &binding.revision != &environment_profile.profile_revision
5220 || &binding.provider != provider
5221 {
5222 return Err(failure(
5223 NodeFailureCode::EnvironmentProfileBindingMismatch,
5224 "session environment profile revision or provider changed",
5225 ));
5226 }
5227 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5228 failure(
5229 NodeFailureCode::EnvironmentProfileBindingMismatch,
5230 "session environment profile does not support this mode",
5231 )
5232 })?
5233 };
5234 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5235 failure(
5236 NodeFailureCode::BackendOperationFailed,
5237 "native environment profile controller is unavailable",
5238 )
5239 })?;
5240 control
5241 .select_native_launch_profile(instance_id, native_profile_id)
5242 .map_err(|_| {
5243 failure(
5244 NodeFailureCode::BackendOperationFailed,
5245 "native environment profile selection failed",
5246 )
5247 })?;
5248 Ok(Some(NativeEnvironmentSelectionGuard {
5249 control: Some(control),
5250 instance_id,
5251 }))
5252 }
5253
5254 fn install_prepared_launch_overlay(
5255 &self,
5256 instance_id: AgentInstanceId,
5257 overlay: PreparedNativeLaunchOverlay,
5258 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5259 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5260 failure(
5261 NodeFailureCode::BackendOperationFailed,
5262 "native launch profile controller is unavailable",
5263 )
5264 })?;
5265 match overlay {
5266 PreparedNativeLaunchOverlay::Environment(overlay) => {
5267 control
5268 .install_native_launch_environment_overlay(instance_id, overlay)
5269 .map_err(|_| failure(
5270 NodeFailureCode::BackendOperationFailed,
5271 "session environment overlay installation failed",
5272 ))?;
5273 Ok(None)
5274 }
5275 PreparedNativeLaunchOverlay::Instance(overlay) => {
5276 control
5277 .install_native_instance_launch_overlay(instance_id, overlay)
5278 .map_err(|_| failure(
5279 NodeFailureCode::BundleBindingMismatch,
5280 "session bundle launch overlay installation failed",
5281 ))?;
5282 Ok(Some(NativeInstanceOverlayGuard {
5283 control: Some(control),
5284 instance_id,
5285 }))
5286 }
5287 }
5288 }
5289
5290 fn environment_materialization_profile(
5291 &self,
5292 environment_profile: &ResolvedEnvironmentProfileReceipt,
5293 ) -> Option<NodeSessionMaterializationProfile> {
5294 self.environment_materialization_profiles
5295 .read()
5296 .unwrap_or_else(|poisoned| poisoned.into_inner())
5297 .get(&environment_profile.profile_id)
5298 .cloned()
5299 }
5300
5301 fn materialization_profile(
5302 &self,
5303 provider: &AgentId,
5304 mode: SessionMode,
5305 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5306 bundle: Option<&ResolvedBundleReceipt>,
5307 context: Option<&ResolvedContextPackReceipt>,
5308 ) -> Result<Option<(
5309 NodeSessionMaterializationProfile,
5310 bool,
5311 Option<BundleProviderLayout>,
5312 )>, NodeFailure> {
5313 let environment = environment_profile
5314 .and_then(|receipt| self.environment_materialization_profile(receipt));
5315 let (bundle, bundle_layout) = match bundle {
5316 Some(receipt) => {
5317 let catalog = self.bundle_catalog.read()
5318 .unwrap_or_else(|poisoned| poisoned.into_inner());
5319 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5320 NodeFailureCode::UnknownBundle,
5321 "session bundle is unavailable on this node",
5322 ))?;
5323 if installed.receipt() != *receipt {
5324 return Err(failure(
5325 NodeFailureCode::BundleBindingMismatch,
5326 "session bundle revision or digest changed",
5327 ));
5328 }
5329 let layout = self.bundle_layout(
5330 provider,
5331 mode,
5332 environment_profile,
5333 installed,
5334 )?;
5335 (Some(installed.clone()), Some(layout))
5336 }
5337 None => (None, None),
5338 };
5339 let has_environment_overlay = environment.is_some() || context.is_some();
5340 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5341 (None, None, None) => None,
5342 (Some(profile), None, None) => Some(Ok(profile)),
5343 (None, Some(bundle), Some(layout)) => {
5344 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5345 }
5346 (Some(profile), Some(bundle), Some(layout)) => {
5347 Some(profile.with_bundle(bundle, layout))
5348 }
5349 _ => {
5350 return Err(failure(
5351 NodeFailureCode::BundleBindingMismatch,
5352 "session bundle provider layout is unavailable",
5353 ));
5354 }
5355 };
5356 let profile = profile
5357 .transpose()
5358 .map_err(|_| failure(
5359 NodeFailureCode::BundleMaterializationFailed,
5360 "session bundle materialization profile is invalid",
5361 ))?;
5362 let profile = match (profile, context) {
5363 (Some(profile), Some(receipt)) => {
5364 let catalog = self.context_catalog
5365 .read()
5366 .unwrap_or_else(|poisoned| poisoned.into_inner());
5367 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5368 NodeFailureCode::UnknownContextPack,
5369 "session context pack is unavailable on this node",
5370 ))?;
5371 if pack.receipt() != receipt {
5372 return Err(failure(
5373 NodeFailureCode::ContextPackMaterializationFailed,
5374 "session context pack identity changed",
5375 ));
5376 }
5377 profile.with_context(pack)
5378 }
5379 (None, Some(receipt)) => {
5380 let catalog = self.context_catalog
5381 .read()
5382 .unwrap_or_else(|poisoned| poisoned.into_inner());
5383 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5384 NodeFailureCode::UnknownContextPack,
5385 "session context pack is unavailable on this node",
5386 ))?;
5387 if pack.receipt() != receipt {
5388 return Err(failure(
5389 NodeFailureCode::ContextPackMaterializationFailed,
5390 "session context pack identity changed",
5391 ));
5392 }
5393 NodeSessionMaterializationProfile::from_context(pack)
5394 }
5395 (Some(profile), None) => Ok(profile),
5396 (None, None) => return Ok(None),
5397 }
5398 .map_err(|_| failure(
5399 NodeFailureCode::ContextPackMaterializationFailed,
5400 "session context pack materialization profile is invalid",
5401 ))?;
5402 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5403 }
5404
5405 fn prepare_session_materialization<'a>(
5406 &'a self,
5407 address: &SessionAddress,
5408 provider: &AgentId,
5409 mode: SessionMode,
5410 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5411 bundle: Option<&ResolvedBundleReceipt>,
5412 context: Option<&ResolvedContextPackReceipt>,
5413 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5414 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5415 let Some((profile, has_environment_overlay, bundle_layout)) =
5416 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5417 else {
5418 return Ok(None);
5419 };
5420 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5421 failure(
5422 NodeFailureCode::BackendOperationFailed,
5423 "session environment materializer is unavailable",
5424 )
5425 })?;
5426 let materialization_failure_code = if context.is_some() {
5427 NodeFailureCode::ContextPackMaterializationFailed
5428 } else if bundle.is_some() {
5429 NodeFailureCode::BundleMaterializationFailed
5430 } else {
5431 NodeFailureCode::BackendOperationFailed
5432 };
5433 let id = MaterializationId::new(format!(
5434 "mat-{}-{}-{}",
5435 self.incarnation_id,
5436 address.session.instance_id.0,
5437 address.session.generation.0,
5438 ))
5439 .map_err(|_| {
5440 failure(
5441 NodeFailureCode::BackendOperationFailed,
5442 "session environment materialization identity failed",
5443 )
5444 })?;
5445 let owner = MaterializationOwner::Session {
5446 incarnation_id: self.incarnation_id,
5447 instance_id: address.session.instance_id,
5448 generation: address.session.generation,
5449 };
5450 let mut ownership = materializer
5451 .begin(
5452 id.clone(),
5453 environment_profile.cloned(),
5454 bundle.cloned(),
5455 context.cloned(),
5456 owner,
5457 managed_lease_id,
5458 &profile,
5459 unix_time_ms(),
5460 )
5461 .map_err(|_| {
5462 failure(
5463 materialization_failure_code,
5464 "session materialization preparation failed",
5465 )
5466 })?;
5467 {
5468 let _transaction = self
5469 .state_transaction
5470 .lock()
5471 .unwrap_or_else(|poisoned| poisoned.into_inner());
5472 let duplicate = self
5473 .materializations
5474 .lock()
5475 .unwrap_or_else(|poisoned| poisoned.into_inner())
5476 .insert(id.clone(), ownership.clone());
5477 if duplicate.is_some() {
5478 return Err(failure(
5479 NodeFailureCode::BackendOperationFailed,
5480 "session environment materialization identity conflict",
5481 ));
5482 }
5483 if let Err(error) = self.persist_state_locked() {
5484 self.materializations
5485 .lock()
5486 .unwrap_or_else(|poisoned| poisoned.into_inner())
5487 .remove(&id);
5488 return Err(persistence_failure(error));
5489 }
5490 }
5491 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5492 Ok(environment) => environment,
5493 Err(_) => {
5494 let transaction = self
5495 .state_transaction
5496 .lock()
5497 .unwrap_or_else(|poisoned| poisoned.into_inner());
5498 self.materializations
5499 .lock()
5500 .unwrap_or_else(|poisoned| poisoned.into_inner())
5501 .insert(id.clone(), ownership.clone());
5502 if self.persist_state_locked().is_err() {
5503 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5504 }
5505 drop(transaction);
5506 if ownership.state() == MaterializationState::CleanupRequired {
5507 let _ = self.cleanup_materialization(&id);
5508 }
5509 return Err(failure(
5510 materialization_failure_code,
5511 "session materialization failed",
5512 ));
5513 }
5514 };
5515 {
5516 let _transaction = self
5517 .state_transaction
5518 .lock()
5519 .unwrap_or_else(|poisoned| poisoned.into_inner());
5520 self.materializations
5521 .lock()
5522 .unwrap_or_else(|poisoned| poisoned.into_inner())
5523 .insert(id.clone(), ownership.clone());
5524 if let Err(error) = self.persist_state_locked() {
5525 drop(_transaction);
5526 let _ = self.cleanup_materialization(&id);
5527 return Err(persistence_failure(error));
5528 }
5529 }
5530 let guard = SessionMaterializationGuard {
5531 shared: self,
5532 id: Some(id),
5533 provider_home: Some(ownership.provider_home().to_path_buf()),
5534 };
5535 let bundle_arguments = match bundle {
5536 Some(receipt) => {
5537 let catalog = self.bundle_catalog.read()
5538 .unwrap_or_else(|poisoned| poisoned.into_inner());
5539 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5540 NodeFailureCode::UnknownBundle,
5541 "session bundle is unavailable on this node",
5542 ))?;
5543 if installed.receipt() != *receipt {
5544 return Err(failure(
5545 NodeFailureCode::BundleBindingMismatch,
5546 "session bundle revision or digest changed",
5547 ));
5548 }
5549 let layout = bundle_layout.ok_or_else(|| failure(
5550 NodeFailureCode::BundleBindingMismatch,
5551 "materialized bundle provider layout is unavailable",
5552 ))?;
5553 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5554 failure(
5555 NodeFailureCode::BundleMaterializationFailed,
5556 "session bundle byte revalidation failed",
5557 )
5558 })?;
5559 Some(bundle_launch_arguments(
5560 layout,
5561 ownership.bundle_root(),
5562 ownership.provider_home(),
5563 ).map_err(|_| failure(
5564 NodeFailureCode::BundleBindingMismatch,
5565 "session bundle launch binding is invalid",
5566 ))?)
5567 }
5568 None => None,
5569 };
5570 if let Some(receipt) = context {
5571 materializer
5572 .revalidate_context(&ownership, receipt)
5573 .map_err(|_| failure(
5574 NodeFailureCode::ContextPackMaterializationFailed,
5575 "session context pack byte revalidation failed",
5576 ))?;
5577 }
5578 let transport = match mode {
5579 SessionMode::Pty => TransportKind::Pty,
5580 SessionMode::Inline => TransportKind::Pipe,
5581 SessionMode::Acp => TransportKind::Acp,
5582 };
5583 let overlay = if let Some(extra_args) = bundle_arguments {
5584 Some(PreparedNativeLaunchOverlay::Instance(
5585 NativeInstanceLaunchOverlay::new(
5586 provider.clone(),
5587 transport,
5588 if has_environment_overlay {
5589 environment
5590 } else {
5591 Vec::new()
5592 },
5593 extra_args,
5594 )
5595 .map_err(|_| {
5596 failure(
5597 NodeFailureCode::BundleBindingMismatch,
5598 "session bundle launch overlay is invalid",
5599 )
5600 })?,
5601 ))
5602 } else if has_environment_overlay {
5603 let context_only = environment_profile.is_none()
5604 && bundle.is_none()
5605 && context.is_some();
5606 let overlay = if context_only {
5607 NativeLaunchEnvironmentOverlay::new_context_root(
5608 provider.clone(),
5609 transport,
5610 environment,
5611 )
5612 .map_err(|_| {
5613 failure(
5614 NodeFailureCode::ContextPackMaterializationFailed,
5615 "context-only launch overlay is invalid",
5616 )
5617 })?
5618 } else {
5619 NativeLaunchEnvironmentOverlay::new(
5620 provider.clone(),
5621 transport,
5622 environment,
5623 )
5624 .map_err(|_| {
5625 failure(
5626 NodeFailureCode::BackendOperationFailed,
5627 "session environment overlay is invalid",
5628 )
5629 })?
5630 };
5631 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5632 } else {
5633 None
5634 };
5635 Ok(Some((overlay, guard)))
5636 }
5637
5638 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5639 let Some(mut ownership) = self
5640 .materializations
5641 .lock()
5642 .unwrap_or_else(|poisoned| poisoned.into_inner())
5643 .get(id)
5644 .cloned()
5645 else {
5646 return Ok(());
5647 };
5648 if ownership.state() != MaterializationState::CleanupRequired {
5649 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5650 failure(
5651 NodeFailureCode::BackendOperationFailed,
5652 "session environment cleanup state is invalid",
5653 )
5654 })?;
5655 }
5656 {
5657 let _transaction = self
5658 .state_transaction
5659 .lock()
5660 .unwrap_or_else(|poisoned| poisoned.into_inner());
5661 let previous = self
5662 .materializations
5663 .lock()
5664 .unwrap_or_else(|poisoned| poisoned.into_inner())
5665 .insert(id.clone(), ownership.clone());
5666 if let Err(error) = self.persist_state_locked() {
5667 let mut materializations = self
5668 .materializations
5669 .lock()
5670 .unwrap_or_else(|poisoned| poisoned.into_inner());
5671 if let Some(previous) = previous {
5672 materializations.insert(id.clone(), previous);
5673 }
5674 return Err(persistence_failure(error));
5675 }
5676 }
5677 let cleanup = self
5678 .session_environment_materializer
5679 .as_ref()
5680 .ok_or(())
5681 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5682 if cleanup.is_err() {
5683 let _ = ownership.mark_recovery_required(unix_time_ms());
5684 self.materializations
5685 .lock()
5686 .unwrap_or_else(|poisoned| poisoned.into_inner())
5687 .insert(id.clone(), ownership);
5688 if self.persist_state().is_err() {
5689 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5690 }
5691 return Err(failure(
5692 NodeFailureCode::BackendOperationFailed,
5693 "session environment cleanup requires recovery",
5694 ));
5695 }
5696 let _transaction = self
5697 .state_transaction
5698 .lock()
5699 .unwrap_or_else(|poisoned| poisoned.into_inner());
5700 self.materializations
5701 .lock()
5702 .unwrap_or_else(|poisoned| poisoned.into_inner())
5703 .remove(id);
5704 if let Err(error) = self.persist_state_locked() {
5705 self.materializations
5706 .lock()
5707 .unwrap_or_else(|poisoned| poisoned.into_inner())
5708 .insert(id.clone(), ownership);
5709 return Err(persistence_failure(error));
5710 }
5711 Ok(())
5712 }
5713
5714 fn resolve_record_materialization(
5715 &self,
5716 record_id: &SessionRecordId,
5717 provider: &AgentId,
5718 mode: SessionMode,
5719 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5720 bundle: Option<&ResolvedBundleReceipt>,
5721 context: Option<&ResolvedContextPackReceipt>,
5722 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5723 let ownership = {
5724 let materializations = self
5725 .materializations
5726 .lock()
5727 .unwrap_or_else(|poisoned| poisoned.into_inner());
5728 let mut matches = materializations.values().filter(|record| {
5729 record.owner()
5730 == &MaterializationOwner::Record {
5731 record_id: record_id.clone(),
5732 }
5733 });
5734 let ownership = matches.next().cloned();
5735 if matches.next().is_some() {
5736 return Err(failure(
5737 NodeFailureCode::BackendOperationFailed,
5738 "managed session has conflicting environment ownership",
5739 ));
5740 }
5741 ownership
5742 };
5743 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5744 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5745 failure(
5746 NodeFailureCode::ContextPackMaterializationFailed,
5747 "session context materializer is unavailable",
5748 )
5749 })?;
5750 let pack = materializer
5751 .revalidate_context(ownership, receipt)
5752 .map_err(|_| {
5753 self.mark_materialization_recovery_required(ownership.id());
5754 failure(
5755 NodeFailureCode::ContextPackMaterializationFailed,
5756 "managed session context pack revalidation failed",
5757 )
5758 })?;
5759 self.context_catalog
5760 .write()
5761 .unwrap_or_else(|poisoned| poisoned.into_inner())
5762 .insert(pack)
5763 .map_err(|_| failure(
5764 NodeFailureCode::ContextPackMaterializationFailed,
5765 "managed session context pack catalog restore failed",
5766 ))?;
5767 }
5768 let materialization = self.materialization_profile(
5769 provider,
5770 mode,
5771 environment_profile,
5772 bundle,
5773 context,
5774 )?;
5775 match (ownership, materialization) {
5776 (None, None) => Ok(None),
5777 (None, Some(_)) | (Some(_), None) => Err(failure(
5778 NodeFailureCode::BackendOperationFailed,
5779 "managed session materialization is unavailable",
5780 )),
5781 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5782 if ownership.environment_profile() != environment_profile
5783 || ownership.bundle() != bundle
5784 || ownership.context() != context
5785 || ownership.state() != MaterializationState::Ready
5786 {
5787 self.mark_materialization_recovery_required(ownership.id());
5788 return Err(failure(
5789 NodeFailureCode::BackendOperationFailed,
5790 "managed session environment ownership requires recovery",
5791 ));
5792 }
5793 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5794 failure(
5795 NodeFailureCode::BackendOperationFailed,
5796 "session environment materializer is unavailable",
5797 )
5798 })?;
5799 if materializer.revalidate(&ownership).is_err() {
5800 self.mark_materialization_recovery_required(ownership.id());
5801 return Err(failure(
5802 NodeFailureCode::BackendOperationFailed,
5803 "managed session environment revalidation failed",
5804 ));
5805 }
5806 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5807 let catalog = self.bundle_catalog.read()
5808 .unwrap_or_else(|poisoned| poisoned.into_inner());
5809 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5810 return Err(failure(
5811 NodeFailureCode::UnknownBundle,
5812 "managed session bundle is unavailable",
5813 ));
5814 };
5815 let layout = bundle_layout.ok_or_else(|| failure(
5816 NodeFailureCode::BundleBindingMismatch,
5817 "record bundle provider layout is unavailable",
5818 ))?;
5819 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5820 drop(catalog);
5821 self.mark_materialization_recovery_required(ownership.id());
5822 return Err(failure(
5823 NodeFailureCode::BundleMaterializationFailed,
5824 "managed session bundle revalidation failed",
5825 ));
5826 }
5827 Some(bundle_launch_arguments(
5828 layout,
5829 ownership.bundle_root(),
5830 ownership.provider_home(),
5831 ).map_err(|_| failure(
5832 NodeFailureCode::BundleBindingMismatch,
5833 "managed session bundle launch binding is invalid",
5834 ))?)
5835 } else {
5836 None
5837 };
5838 if !has_environment_materialization {
5839 let overlay = bundle_arguments.map(|extra_args| {
5840 NativeInstanceLaunchOverlay::new(
5841 provider.clone(),
5842 TransportKind::Pty,
5843 Vec::new(),
5844 extra_args,
5845 )
5846 .map(PreparedNativeLaunchOverlay::Instance)
5847 .map_err(|_| failure(
5848 NodeFailureCode::BundleBindingMismatch,
5849 "managed session bundle launch overlay is invalid",
5850 ))
5851 }).transpose()?;
5852 return Ok(Some((ownership.id().clone(), overlay)));
5853 }
5854 let environment = match materializer.resolve_environment(&ownership, &profile) {
5855 Ok(environment) => environment,
5856 Err(
5857 SessionEnvironmentMaterializeError::SecretUnavailable
5858 | SessionEnvironmentMaterializeError::SecretDenied
5859 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5860 ) => {
5861 return Err(failure(
5862 NodeFailureCode::BackendOperationFailed,
5863 "managed session environment resolution failed",
5864 ));
5865 }
5866 Err(
5867 SessionEnvironmentMaterializeError::InvalidRoot
5868 | SessionEnvironmentMaterializeError::OwnershipMismatch
5869 | SessionEnvironmentMaterializeError::Filesystem(_)
5870 | SessionEnvironmentMaterializeError::Record(_),
5871 ) => {
5872 self.mark_materialization_recovery_required(ownership.id());
5873 return Err(failure(
5874 NodeFailureCode::BackendOperationFailed,
5875 "managed session environment resolution failed",
5876 ));
5877 }
5878 };
5879 let transport = match mode {
5880 SessionMode::Pty => TransportKind::Pty,
5881 SessionMode::Inline => TransportKind::Pipe,
5882 SessionMode::Acp => TransportKind::Acp,
5883 };
5884 let overlay = match bundle_arguments {
5885 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5886 NativeInstanceLaunchOverlay::new(
5887 provider.clone(),
5888 transport,
5889 environment,
5890 extra_args,
5891 )
5892 .map_err(|_| failure(
5893 NodeFailureCode::BundleBindingMismatch,
5894 "managed session bundle launch overlay is invalid",
5895 ))?,
5896 ),
5897 None => PreparedNativeLaunchOverlay::Environment(
5898 NativeLaunchEnvironmentOverlay::new(
5899 provider.clone(),
5900 transport,
5901 environment,
5902 )
5903 .map_err(|_| {
5904 failure(
5905 NodeFailureCode::BackendOperationFailed,
5906 "managed session environment overlay is invalid",
5907 )
5908 })?,
5909 ),
5910 };
5911 Ok(Some((ownership.id().clone(), Some(overlay))))
5912 }
5913 }
5914 }
5915
5916 fn destroy_record_materialization(
5917 &self,
5918 record_id: &SessionRecordId,
5919 ) -> Result<bool, NodeFailure> {
5920 let ids = self
5921 .materializations
5922 .lock()
5923 .unwrap_or_else(|poisoned| poisoned.into_inner())
5924 .values()
5925 .filter_map(|record| {
5926 (record.owner()
5927 == &MaterializationOwner::Record {
5928 record_id: record_id.clone(),
5929 })
5930 .then(|| record.id().clone())
5931 })
5932 .collect::<Vec<_>>();
5933 if ids.len() > 1 {
5934 return Err(failure(
5935 NodeFailureCode::BackendOperationFailed,
5936 "managed session has conflicting environment ownership",
5937 ));
5938 }
5939 let Some(id) = ids.first().cloned() else {
5940 return Ok(false);
5941 };
5942 let (terminal_record, mut cleanup_ownership) = {
5943 let _transaction = self
5944 .state_transaction
5945 .lock()
5946 .unwrap_or_else(|poisoned| poisoned.into_inner());
5947 let original_record = self.record(record_id)?;
5948 let mut terminal_record = original_record.clone();
5949 terminal_record.provider_session = None;
5950 terminal_record.active_session = None;
5951 terminal_record.state = ManagedSessionState::Unavailable;
5952 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5953 terminal_record.updated_at_unix_ms = unix_time_ms();
5954 let original_ownership = self
5955 .materializations
5956 .lock()
5957 .unwrap_or_else(|poisoned| poisoned.into_inner())
5958 .get(&id)
5959 .cloned()
5960 .ok_or_else(|| {
5961 failure(
5962 NodeFailureCode::BackendOperationFailed,
5963 "managed session environment ownership disappeared during cleanup",
5964 )
5965 })?;
5966 let mut cleanup_ownership = original_ownership.clone();
5967 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5968 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5969 failure(
5970 NodeFailureCode::BackendOperationFailed,
5971 "managed session environment cleanup state is invalid",
5972 )
5973 })?;
5974 }
5975 self.session_records
5976 .lock()
5977 .unwrap_or_else(|poisoned| poisoned.into_inner())
5978 .records
5979 .insert(record_id.clone(), terminal_record.clone());
5980 self.materializations
5981 .lock()
5982 .unwrap_or_else(|poisoned| poisoned.into_inner())
5983 .insert(id.clone(), cleanup_ownership.clone());
5984 if let Err(error) = self.persist_state_locked() {
5985 self.session_records
5986 .lock()
5987 .unwrap_or_else(|poisoned| poisoned.into_inner())
5988 .records
5989 .insert(record_id.clone(), original_record);
5990 self.materializations
5991 .lock()
5992 .unwrap_or_else(|poisoned| poisoned.into_inner())
5993 .insert(id.clone(), original_ownership);
5994 return Err(persistence_failure(error));
5995 }
5996 (terminal_record, cleanup_ownership)
5997 };
5998 let cleanup = self
5999 .session_environment_materializer
6000 .as_ref()
6001 .ok_or(())
6002 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
6003 if cleanup.is_err() {
6004 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
6005 self.materializations
6006 .lock()
6007 .unwrap_or_else(|poisoned| poisoned.into_inner())
6008 .insert(id.clone(), cleanup_ownership);
6009 if self.persist_state().is_err() {
6010 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6011 }
6012 return Err(failure(
6013 NodeFailureCode::BackendOperationFailed,
6014 "managed session environment cleanup requires recovery",
6015 ));
6016 }
6017 let _transaction = self
6018 .state_transaction
6019 .lock()
6020 .unwrap_or_else(|poisoned| poisoned.into_inner());
6021 self.remove_record_memory(record_id);
6022 self.materializations
6023 .lock()
6024 .unwrap_or_else(|poisoned| poisoned.into_inner())
6025 .remove(&id);
6026 if let Err(error) = self.persist_state_locked() {
6027 self.session_records
6028 .lock()
6029 .unwrap_or_else(|poisoned| poisoned.into_inner())
6030 .records
6031 .insert(record_id.clone(), terminal_record);
6032 self.materializations
6033 .lock()
6034 .unwrap_or_else(|poisoned| poisoned.into_inner())
6035 .insert(id, cleanup_ownership);
6036 return Err(persistence_failure(error));
6037 }
6038 Ok(true)
6039 }
6040
6041 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6042 let changed = {
6043 let mut materializations = self
6044 .materializations
6045 .lock()
6046 .unwrap_or_else(|poisoned| poisoned.into_inner());
6047 let Some(ownership) = materializations.get_mut(id) else {
6048 return;
6049 };
6050 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6051 };
6052 if changed && self.persist_state().is_err() {
6053 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6054 }
6055 }
6056
6057 fn reconcile_materializations(&self) {
6058 let records = self
6059 .materializations
6060 .lock()
6061 .unwrap_or_else(|poisoned| poisoned.into_inner())
6062 .values()
6063 .cloned()
6064 .collect::<Vec<_>>();
6065 for mut ownership in records {
6066 let result = match (ownership.state(), ownership.owner()) {
6067 (MaterializationState::Preparing, _)
6068 | (MaterializationState::CleanupRequired, _) => {
6069 self.cleanup_materialization(ownership.id())
6070 }
6071 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6072 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6073 self.materializations
6074 .lock()
6075 .unwrap_or_else(|poisoned| poisoned.into_inner())
6076 .insert(ownership.id().clone(), ownership.clone());
6077 self.persist_state().map_err(persistence_failure)
6078 } else {
6079 Err(failure(
6080 NodeFailureCode::BackendOperationFailed,
6081 "session environment reconciliation failed",
6082 ))
6083 }
6084 }
6085 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6086 .session_environment_materializer
6087 .as_ref()
6088 .ok_or_else(|| {
6089 failure(
6090 NodeFailureCode::BackendOperationFailed,
6091 "session environment materializer is unavailable",
6092 )
6093 })
6094 .and_then(|materializer| {
6095 materializer.revalidate(&ownership).map_err(|_| failure(
6096 NodeFailureCode::BackendOperationFailed,
6097 "session materialization revalidation failed",
6098 ))?;
6099 let record = self.record(record_id)?;
6100 if record.environment_profile.as_ref() != ownership.environment_profile()
6101 || record.bundle.as_ref() != ownership.bundle()
6102 || record.context.as_ref() != ownership.context()
6103 {
6104 return Err(failure(
6105 NodeFailureCode::BackendOperationFailed,
6106 "session materialization ownership changed after restart",
6107 ));
6108 }
6109 if let Some(receipt) = ownership.bundle() {
6110 let catalog = self.bundle_catalog.read()
6111 .unwrap_or_else(|poisoned| poisoned.into_inner());
6112 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6113 NodeFailureCode::UnknownBundle,
6114 "session bundle is unavailable after restart",
6115 ))?;
6116 if bundle.receipt() != *receipt {
6117 return Err(failure(
6118 NodeFailureCode::BundleBindingMismatch,
6119 "session bundle changed after restart",
6120 ));
6121 }
6122 let layout = self.bundle_layout(
6123 &record.provider,
6124 record.mode,
6125 record.environment_profile.as_ref(),
6126 bundle,
6127 )?;
6128 materializer
6129 .revalidate_bundle(&ownership, bundle, layout)
6130 .map_err(|_| failure(
6131 NodeFailureCode::BundleMaterializationFailed,
6132 "session bundle revalidation failed",
6133 ))?;
6134 }
6135 if let Some(receipt) = ownership.context() {
6136 let pack = materializer
6137 .revalidate_context(&ownership, receipt)
6138 .map_err(|_| {
6139 failure(
6140 NodeFailureCode::ContextPackMaterializationFailed,
6141 "session context revalidation failed",
6142 )
6143 })?;
6144 self.context_catalog
6145 .write()
6146 .unwrap_or_else(|poisoned| poisoned.into_inner())
6147 .insert(pack)
6148 .map_err(|_| {
6149 failure(
6150 NodeFailureCode::ContextPackMaterializationFailed,
6151 "session context catalog restore failed",
6152 )
6153 })?;
6154 }
6155 Ok(())
6156 }),
6157 (MaterializationState::RecoveryRequired, _) => Ok(()),
6158 };
6159 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6160 let _ = ownership.mark_recovery_required(unix_time_ms());
6161 self.materializations
6162 .lock()
6163 .unwrap_or_else(|poisoned| poisoned.into_inner())
6164 .insert(ownership.id().clone(), ownership);
6165 if self.persist_state().is_err() {
6166 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6167 }
6168 }
6169 }
6170 let materialized_profiles = self
6171 .environment_materialization_profiles
6172 .read()
6173 .unwrap_or_else(|poisoned| poisoned.into_inner())
6174 .keys()
6175 .cloned()
6176 .collect::<Vec<_>>();
6177 let ownership = self
6178 .materializations
6179 .lock()
6180 .unwrap_or_else(|poisoned| poisoned.into_inner())
6181 .values()
6182 .cloned()
6183 .collect::<Vec<_>>();
6184 let changed = {
6185 let mut session_records = self
6186 .session_records
6187 .lock()
6188 .unwrap_or_else(|poisoned| poisoned.into_inner());
6189 let mut changed = false;
6190 for record in session_records.records.values_mut() {
6191 let environment_profile = record.environment_profile.as_ref();
6192 if environment_profile.is_none()
6193 && record.bundle.is_none()
6194 && record.context.is_none()
6195 {
6196 continue;
6197 }
6198 if record.bundle.is_none()
6199 && environment_profile.is_some_and(|profile| {
6200 !materialized_profiles.contains(&profile.profile_id)
6201 })
6202 {
6203 continue;
6204 }
6205 let has_exact_ownership = ownership.iter().any(|candidate| {
6206 candidate.state() == MaterializationState::Ready
6207 && candidate.environment_profile() == environment_profile
6208 && candidate.bundle() == record.bundle.as_ref()
6209 && candidate.context() == record.context.as_ref()
6210 && candidate.owner()
6211 == &MaterializationOwner::Record {
6212 record_id: record.record_id.clone(),
6213 }
6214 });
6215 if !has_exact_ownership {
6216 record.provider_session = None;
6217 record.active_session = None;
6218 record.state = ManagedSessionState::Unavailable;
6219 record.last_error = Some(if record.bundle.is_some() {
6220 "bundle-unavailable".to_owned()
6221 } else if record.context.is_some() {
6222 "context-unavailable".to_owned()
6223 } else {
6224 "environment-profile-unavailable".to_owned()
6225 });
6226 record.updated_at_unix_ms = unix_time_ms();
6227 changed = true;
6228 }
6229 }
6230 changed
6231 };
6232 if changed && self.persist_state().is_err() {
6233 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6234 }
6235 }
6236
6237 fn replay_spawn_spec(
6238 &self,
6239 spec: &SpawnSpec,
6240 now: Instant,
6241 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6242 let mut cache = self
6243 .spawn_idempotency
6244 .lock()
6245 .unwrap_or_else(|poisoned| poisoned.into_inner());
6246 cache.entries.retain(|_, entry| entry.expires_at > now);
6247 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6248 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6249 return Err(failure(
6250 NodeFailureCode::SpawnIdempotencyConflict,
6251 "spawn idempotency key was reused across spawn kinds",
6252 ));
6253 };
6254 if existing != spec {
6255 return Err(failure(
6256 NodeFailureCode::SpawnIdempotencyConflict,
6257 "spawn idempotency key was reused with a different specification",
6258 ));
6259 }
6260 return Ok(Some(result.clone()));
6261 }
6262 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6263 return Err(failure(
6264 NodeFailureCode::SpawnIdempotencyCapacity,
6265 "spawn idempotency cache reached its bounded capacity",
6266 ));
6267 }
6268 Ok(None)
6269 }
6270
6271 fn remember_spawn_spec(
6272 &self,
6273 spec: SpawnSpec,
6274 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6275 accepted_at: Instant,
6276 ) {
6277 self
6278 .spawn_idempotency
6279 .lock()
6280 .unwrap_or_else(|poisoned| poisoned.into_inner())
6281 .entries
6282 .insert(
6283 spec.idempotency_key.clone(),
6284 SpawnIdempotencyEntry {
6285 value: SpawnIdempotencyValue::Standard { spec, result },
6286 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6287 },
6288 );
6289 }
6290
6291 fn claim_harness_mcp_spawn(
6292 &self,
6293 _reservation_id: &HarnessMcpReservationId,
6294 _activation_digest: &HarnessMcpActivationDigest,
6295 spec: &SpawnSpec,
6296 now: Instant,
6297 ) -> Result<(), NodeFailure> {
6298 let mut cache = self.spawn_idempotency.lock()
6299 .unwrap_or_else(|poisoned| poisoned.into_inner());
6300 cache.entries.retain(|_, entry| entry.expires_at > now);
6301 if cache.entries.contains_key(&spec.idempotency_key) {
6302 return Err(failure(
6303 NodeFailureCode::SpawnIdempotencyConflict,
6304 "spawn idempotency key was reused across spawn kinds",
6305 ));
6306 }
6307 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6308 return Err(failure(
6309 NodeFailureCode::SpawnIdempotencyCapacity,
6310 "spawn idempotency cache reached its bounded capacity",
6311 ));
6312 }
6313 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6314 value: SpawnIdempotencyValue::HarnessMcp,
6315 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6316 });
6317 Ok(())
6318 }
6319
6320 fn replay_managed_spawn(
6321 &self,
6322 request: &ManagedWorktreeSpawnRequest,
6323 now: Instant,
6324 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6325 let mut cache = self.spawn_idempotency.lock()
6326 .unwrap_or_else(|poisoned| poisoned.into_inner());
6327 cache.entries.retain(|_, entry| entry.expires_at > now);
6328 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6329 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6330 return Err(failure(
6331 NodeFailureCode::SpawnIdempotencyConflict,
6332 "spawn idempotency key was reused across spawn kinds",
6333 ));
6334 };
6335 if existing != request {
6336 return Err(failure(
6337 NodeFailureCode::SpawnIdempotencyConflict,
6338 "spawn idempotency key was reused with a different managed specification",
6339 ));
6340 }
6341 return Ok(Some(result.clone()));
6342 }
6343 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6344 return Err(failure(
6345 NodeFailureCode::SpawnIdempotencyCapacity,
6346 "spawn idempotency cache reached its bounded capacity",
6347 ));
6348 }
6349 Ok(None)
6350 }
6351
6352 fn remember_managed_spawn(
6353 &self,
6354 request: ManagedWorktreeSpawnRequest,
6355 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6356 accepted_at: Instant,
6357 ) {
6358 self.spawn_idempotency.lock()
6359 .unwrap_or_else(|poisoned| poisoned.into_inner())
6360 .entries
6361 .insert(
6362 request.spawn_spec.idempotency_key.clone(),
6363 SpawnIdempotencyEntry {
6364 value: SpawnIdempotencyValue::Managed { request, result },
6365 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6366 },
6367 );
6368 }
6369
6370 fn remember_managed_spawn_attempt(
6371 &self,
6372 durable_v2_key: Option<&SpawnIdempotencyKey>,
6373 request: ManagedWorktreeSpawnRequest,
6374 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6375 accepted_at: Instant,
6376 ) {
6377 if durable_v2_key.is_none() {
6378 self.remember_managed_spawn(request, result, accepted_at);
6379 }
6380 }
6381
6382 fn acquire_durable_managed_spawn_v2(
6383 &self,
6384 request: &ManagedWorktreeSpawnRequestV2,
6385 request_digest: String,
6386 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6387 if self.state_path.is_none() {
6388 return Err(failure(
6389 NodeFailureCode::BackendOperationFailed,
6390 "managed worktree V2 requires durable node state",
6391 ));
6392 }
6393 let _transaction = self.state_transaction
6394 .lock()
6395 .unwrap_or_else(|poisoned| poisoned.into_inner());
6396 let key = request.spawn_spec.idempotency_key.clone();
6397 let mut previous = None;
6398 {
6399 let mut replays = self.managed_spawn_replays
6400 .lock()
6401 .unwrap_or_else(|poisoned| poisoned.into_inner());
6402 if let Some(record) = replays.get_mut(&key) {
6403 if record.request_digest != request_digest {
6404 return Err(failure(
6405 NodeFailureCode::SpawnIdempotencyConflict,
6406 "managed worktree V2 idempotency key was reused with a different request",
6407 ));
6408 }
6409 match &record.state {
6410 ManagedWorktreeSpawnReplayStateV10::Pending {
6411 owner_incarnation_id,
6412 lease_id: None,
6413 } if owner_incarnation_id != &self.incarnation_id => {
6414 previous = Some(record.clone());
6415 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6416 owner_incarnation_id: self.incarnation_id,
6417 lease_id: None,
6418 };
6419 record.updated_at_unix_ms = unix_time_ms();
6420 }
6421 ManagedWorktreeSpawnReplayStateV10::Pending {
6422 lease_id: None,
6423 ..
6424 } => {
6425 return Err(failure(
6426 NodeFailureCode::BackendBusy,
6427 "managed worktree V2 reservation is already active",
6428 ));
6429 }
6430 ManagedWorktreeSpawnReplayStateV10::Pending {
6431 lease_id: Some(_),
6432 ..
6433 } => {
6434 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6435 }
6436 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6437 return Ok(DurableManagedSpawnReplayDecision::Committed(
6438 receipt.clone(),
6439 ));
6440 }
6441 }
6442 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6443 return Err(failure(
6444 NodeFailureCode::SpawnIdempotencyCapacity,
6445 "durable managed worktree replay ledger reached its bounded capacity",
6446 ));
6447 } else {
6448 let now = unix_time_ms();
6449 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6450 idempotency_key: key.clone(),
6451 request_digest,
6452 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6453 profile_id: request.worktree_profile_id.clone(),
6454 expected_profile_revision: request.expected_profile_revision.clone(),
6455 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6456 owner_incarnation_id: self.incarnation_id,
6457 lease_id: None,
6458 },
6459 created_at_unix_ms: now,
6460 updated_at_unix_ms: now,
6461 });
6462 }
6463 }
6464 if let Err(error) = self.persist_state_locked() {
6465 let mut replays = self.managed_spawn_replays
6466 .lock()
6467 .unwrap_or_else(|poisoned| poisoned.into_inner());
6468 match previous {
6469 Some(previous) => {
6470 replays.insert(key, previous);
6471 }
6472 None => {
6473 replays.remove(&key);
6474 }
6475 }
6476 return Err(persistence_failure(error));
6477 }
6478 Ok(DurableManagedSpawnReplayDecision::Reserved)
6479 }
6480
6481 fn link_durable_managed_spawn_v2_lease_locked(
6482 &self,
6483 key: &SpawnIdempotencyKey,
6484 lease_id: &ManagedWorktreeLeaseId,
6485 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6486 let mut replays = self.managed_spawn_replays
6487 .lock()
6488 .unwrap_or_else(|poisoned| poisoned.into_inner());
6489 let record = replays.get_mut(key).ok_or_else(|| {
6490 failure(
6491 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6492 "managed worktree V2 reservation disappeared before allocation",
6493 )
6494 })?;
6495 let previous = record.clone();
6496 match &mut record.state {
6497 ManagedWorktreeSpawnReplayStateV10::Pending {
6498 lease_id: current,
6499 ..
6500 }
6501 if current.is_none() =>
6502 {
6503 *current = Some(lease_id.clone());
6504 record.updated_at_unix_ms = unix_time_ms();
6505 Ok(previous)
6506 }
6507 _ => Err(failure(
6508 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6509 "managed worktree V2 reservation is not allocation-ready",
6510 )),
6511 }
6512 }
6513
6514 fn restore_durable_managed_spawn_v2_locked(
6515 &self,
6516 key: &SpawnIdempotencyKey,
6517 previous: ManagedWorktreeSpawnReplayRecordV10,
6518 ) {
6519 self.managed_spawn_replays
6520 .lock()
6521 .unwrap_or_else(|poisoned| poisoned.into_inner())
6522 .insert(key.clone(), previous);
6523 }
6524
6525 fn commit_durable_managed_spawn_v2(
6526 &self,
6527 request: &ManagedWorktreeSpawnRequestV2,
6528 receipt: ManagedWorktreeSpawnReceipt,
6529 ) -> Result<(), NodeFailure> {
6530 let _transaction = self.state_transaction
6531 .lock()
6532 .unwrap_or_else(|poisoned| poisoned.into_inner());
6533 let key = &request.spawn_spec.idempotency_key;
6534 let previous = {
6535 let mut replays = self.managed_spawn_replays
6536 .lock()
6537 .unwrap_or_else(|poisoned| poisoned.into_inner());
6538 let record = replays.get_mut(key).ok_or_else(|| {
6539 failure(
6540 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6541 "managed worktree V2 reservation disappeared before commit",
6542 )
6543 })?;
6544 let previous = record.clone();
6545 match &record.state {
6546 ManagedWorktreeSpawnReplayStateV10::Pending {
6547 lease_id: Some(lease_id),
6548 ..
6549 } if lease_id == &receipt.lease.lease_id => {}
6550 _ => {
6551 return Err(failure(
6552 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6553 "managed worktree V2 reservation is not linked to the spawned lease",
6554 ));
6555 }
6556 }
6557 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6558 record.updated_at_unix_ms = unix_time_ms();
6559 previous
6560 };
6561 if let Err(error) = self.persist_state_locked() {
6562 self.restore_durable_managed_spawn_v2_locked(key, previous);
6563 return Err(persistence_failure(error));
6564 }
6565 Ok(())
6566 }
6567
6568 fn remove_unallocated_durable_managed_spawn_v2(
6569 &self,
6570 request: &ManagedWorktreeSpawnRequestV2,
6571 ) -> Result<(), NodeFailure> {
6572 let _transaction = self.state_transaction
6573 .lock()
6574 .unwrap_or_else(|poisoned| poisoned.into_inner());
6575 let key = &request.spawn_spec.idempotency_key;
6576 let removed = {
6577 let mut replays = self.managed_spawn_replays
6578 .lock()
6579 .unwrap_or_else(|poisoned| poisoned.into_inner());
6580 match replays.get(key).map(|record| &record.state) {
6581 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6582 lease_id: None,
6583 ..
6584 }) => {
6585 replays.remove(key)
6586 }
6587 _ => None,
6588 }
6589 };
6590 let Some(removed) = removed else {
6591 return Ok(());
6592 };
6593 if let Err(error) = self.persist_state_locked() {
6594 self.managed_spawn_replays
6595 .lock()
6596 .unwrap_or_else(|poisoned| poisoned.into_inner())
6597 .insert(key.clone(), removed);
6598 return Err(persistence_failure(error));
6599 }
6600 Ok(())
6601 }
6602
6603 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6604 if spec.target.node_id != self.node_id {
6605 return Err(failure(
6606 NodeFailureCode::SpawnTargetMismatch,
6607 "spawn target does not match this node",
6608 ));
6609 }
6610 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6611 failure(
6612 NodeFailureCode::UnknownSpawnProfile,
6613 "spawn profile is unavailable on this node",
6614 )
6615 })?;
6616 let resolved = spec.resolve(defaults).map_err(|error| match error {
6617 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6618 NodeFailureCode::SpawnProfileRevisionMismatch,
6619 "spawn profile revision does not match the loaded profile",
6620 ),
6621 _ => failure(
6622 NodeFailureCode::InvalidRequest,
6623 "spawn specification could not be resolved",
6624 ),
6625 })?;
6626 if resolved.target.worktree_id.is_some() {
6627 self.require_worktree_service(&resolved.target.workspace_id)?;
6628 }
6629 let environment_profile = self.resolve_environment_profile(&resolved)?;
6630 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6631 self.resolve_context(&resolved)?;
6632 Ok(resolved)
6633 }
6634
6635 async fn resolve_spawn_workspace(
6636 &self,
6637 resolved: &ResolvedSpawnSpec,
6638 deadline: Instant,
6639 ) -> Result<WorkspaceId, NodeFailure> {
6640 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6641 return Ok(resolved.target.workspace_id.clone());
6642 };
6643 if self.managed_worktrees.lock()
6644 .unwrap_or_else(|poisoned| poisoned.into_inner())
6645 .lease_for_workspace(worktree_id)
6646 .is_some()
6647 {
6648 return Err(failure(
6649 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6650 "managed worktree targets may only be spawned through their lease request",
6651 ));
6652 }
6653 self.require_worktree_service(&resolved.target.workspace_id)?;
6654 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6655 let selected_root = self.workspace_root(worktree_id)?;
6656 let remaining = spawn_deadline_remaining(deadline)?;
6657 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6658 .await
6659 .map_err(|_| {
6660 failure(
6661 NodeFailureCode::SpawnDeadlineExceeded,
6662 "Git worktree selection exceeded the spawn deadline",
6663 )
6664 })?
6665 .map_err(git_worktree_failure)?;
6666 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6667 Ok(worktree_id.clone())
6668 }
6669
6670 async fn spawn_from_spec(
6671 &self,
6672 spec: SpawnSpec,
6673 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6674 self.spawn_from_spec_with_proxy(spec, None).await
6675 }
6676
6677 async fn spawn_from_spec_with_proxy(
6678 &self,
6679 spec: SpawnSpec,
6680 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6681 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6682 let accepted_at = Instant::now();
6683 if harness_mcp.is_none() {
6684 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6685 return replayed;
6686 }
6687 }
6688 let resolved = self.resolve_spawn_spec(&spec)?;
6689 let environment_profile = self.resolve_environment_profile(&resolved)?;
6690 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6691 let context = self.resolve_context(&resolved)?;
6692 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6693 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6694 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6695 let spawn_workspace_id = match spawn_workspace_id {
6696 Ok(workspace_id) => workspace_id,
6697 Err(error) => {
6698 let result = Err(error);
6699 if harness_mcp.is_none() {
6700 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6701 }
6702 return result;
6703 }
6704 };
6705 if self.managed_worktrees.lock()
6706 .unwrap_or_else(|poisoned| poisoned.into_inner())
6707 .lease_for_workspace(&spawn_workspace_id)
6708 .is_some()
6709 {
6710 let result = Err(failure(
6711 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6712 "managed worktree targets may only be spawned through their lease request",
6713 ));
6714 if harness_mcp.is_none() {
6715 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6716 }
6717 return result;
6718 }
6719 let result = self
6720 .spawn_session_with_deadline(
6721 spawn_workspace_id,
6722 resolved.provider.clone(),
6723 resolved.mode,
6724 resolved.terminal_size,
6725 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6726 environment_profile.clone(),
6727 bundle.clone(),
6728 context.clone(),
6729 None,
6730 None,
6731 SpawnRecordPolicy::Always,
6732 Some(deadline),
6733 &required_capabilities,
6734 harness_mcp,
6735 resolved.approval_level,
6736 resolved.network_allowlist.clone(),
6737 resolved.browser_profile_id.clone(),
6738 )
6739 .await
6740 .map(|(session, _runtime_policy)| {
6741 resolved.receipt_with_materialization(
6742 self.incarnation_id,
6743 session,
6744 environment_profile,
6745 bundle,
6746 context,
6747 )
6748 });
6749 if harness_mcp.is_none() {
6750 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6751 }
6752 result
6753 }
6754
6755 async fn spawn_from_spec_with_harness_mcp(
6756 &self,
6757 reservation_id: HarnessMcpReservationId,
6758 activation_digest: HarnessMcpActivationDigest,
6759 spec: SpawnSpec,
6760 deadline_unix_ms: u64,
6761 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6762 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6763 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6764 })?;
6765 if let Some(receipt) = registry
6766 .replay_spawn(&reservation_id, &activation_digest, &spec)
6767 .map_err(harness_mcp_failure)?
6768 {
6769 return Ok(receipt);
6770 }
6771 let resolved = self.resolve_spawn_spec(&spec)?;
6772 let prepared = registry
6773 .prepare_spawn(
6774 &reservation_id,
6775 &activation_digest,
6776 &spec,
6777 deadline_unix_ms,
6778 resolved.provider.clone(),
6779 )
6780 .map_err(harness_mcp_failure)?;
6781 self.claim_harness_mcp_spawn(
6782 &reservation_id,
6783 &activation_digest,
6784 &spec,
6785 Instant::now(),
6786 )?;
6787 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6788 let mut receipt = match spawn {
6789 Ok(receipt) => receipt,
6790 Err(error) => {
6791 let _ = registry.abort(&reservation_id, &activation_digest);
6792 return Err(error);
6793 }
6794 };
6795 let record_id = self
6796 .session_bindings
6797 .lock()
6798 .unwrap_or_else(|poisoned| poisoned.into_inner())
6799 .get(&receipt.session.session.instance_id)
6800 .and_then(|binding| binding.record_id.clone());
6801 let record_id = match record_id {
6802 Some(record_id) => record_id,
6803 None => {
6804 let rollback = self.remove_session(&receipt.session).await;
6805 let _ = registry.abort(&reservation_id, &activation_digest);
6806 if let Err(rollback_error) = rollback {
6807 return Err(rollback_error);
6808 }
6809 return Err(failure(
6810 NodeFailureCode::BindingMismatch,
6811 "harness MCP spawn did not retain its exact managed record",
6812 ));
6813 }
6814 };
6815 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6816 reservation_id: reservation_id.clone(),
6817 activation_digest: activation_digest.clone(),
6818 });
6819 if let Err(error) = registry.mark_spawned(
6820 &prepared,
6821 receipt.session.clone(),
6822 record_id,
6823 receipt.clone(),
6824 ) {
6825 let rollback = self.remove_session(&receipt.session).await;
6826 let _ = registry.abort(&reservation_id, &activation_digest);
6827 if let Err(rollback_error) = rollback {
6828 return Err(rollback_error);
6829 }
6830 return Err(harness_mcp_failure(error));
6831 }
6832 Ok(receipt)
6833 }
6834
6835 async fn spawn_managed_worktree(
6836 &self,
6837 request: ManagedWorktreeSpawnRequest,
6838 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6839 self.spawn_managed_worktree_inner(request, None).await
6840 }
6841
6842 async fn spawn_managed_worktree_inner(
6843 &self,
6844 request: ManagedWorktreeSpawnRequest,
6845 durable_v2_key: Option<&SpawnIdempotencyKey>,
6846 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6847 let accepted_at = Instant::now();
6848 if durable_v2_key.is_none() {
6849 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6850 return replayed;
6851 }
6852 }
6853 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6854 if request.spawn_spec.target.worktree_id.is_some() {
6855 let result = Err(failure(
6856 NodeFailureCode::InvalidRequest,
6857 "managed worktree spawn must not select a caller-provided worktree",
6858 ));
6859 self.remember_managed_spawn_attempt(
6860 durable_v2_key,
6861 request,
6862 result.clone(),
6863 accepted_at,
6864 );
6865 return result;
6866 }
6867 let environment_profile = self.resolve_environment_profile(&resolved)?;
6868 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6869 let context = self.resolve_context(&resolved)?;
6870 let source_workspace_id = resolved.target.workspace_id.clone();
6871 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6872 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6873 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6874 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6875 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6876 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6877 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6878 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6879 };
6880 let runtime_policy = timeout(
6881 spawn_deadline_remaining(deadline)?,
6882 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6883 ).await.map_err(|_| failure(
6884 NodeFailureCode::SpawnDeadlineExceeded,
6885 "provider admission exceeded the managed spawn deadline",
6886 ))??;
6887 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6888 return Err(failure(
6889 NodeFailureCode::UnsupportedSpawnCapability,
6890 "provider runtime does not admit a required spawn capability",
6891 ));
6892 }
6893 self.ensure_binding_capacity()?;
6894 let source_root = self.workspace_root(&source_workspace_id)?;
6895 profile.validate_for_workspace(&source_root)
6896 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6897 let base_timeout_ms = u64::try_from(
6898 spawn_deadline_remaining(deadline)?.as_millis(),
6899 ).unwrap_or(u64::MAX).max(1);
6900 let base_commit = resolve_base_commit_with_timeout(
6901 &source_root,
6902 profile.base(),
6903 base_timeout_ms,
6904 ).await.map_err(|error| {
6905 if Instant::now() >= deadline || error.message.contains("timed out") {
6906 failure(
6907 NodeFailureCode::SpawnDeadlineExceeded,
6908 "managed worktree base resolution exceeded the spawn deadline",
6909 )
6910 } else {
6911 managed_git_worktree_failure(error)
6912 }
6913 })?;
6914
6915 let lease = {
6916 let _transaction = self.state_transaction.lock()
6917 .unwrap_or_else(|poisoned| poisoned.into_inner());
6918 let previous = self.managed_worktrees.lock()
6919 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6920 let lease = self.managed_worktrees.lock()
6921 .unwrap_or_else(|poisoned| poisoned.into_inner())
6922 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6923 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6924 let previous_replay = match durable_v2_key
6925 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6926 .transpose()
6927 {
6928 Ok(previous) => previous,
6929 Err(error) => {
6930 *self.managed_worktrees.lock()
6931 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6932 return Err(error);
6933 }
6934 };
6935 if let Err(error) = self.persist_state_locked() {
6936 *self.managed_worktrees.lock()
6937 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6938 if let (Some(key), Some(previous_replay)) =
6939 (durable_v2_key, previous_replay)
6940 {
6941 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6942 }
6943 return Err(persistence_failure(error));
6944 }
6945 lease
6946 };
6947 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6948 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6949 self.managed_worktrees.lock()
6950 .unwrap_or_else(|poisoned| poisoned.into_inner())
6951 .remove_unmutated(&lease.lease_id);
6952 if self.persist_state().is_err() {
6953 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6954 }
6955 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6956 self.remember_managed_spawn_attempt(
6957 durable_v2_key,
6958 request,
6959 result.clone(),
6960 accepted_at,
6961 );
6962 return result;
6963 }
6964 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6965 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6966 Err(error) => {
6967 self.reconcile_failed_allocation(&lease, &source_root).await;
6968 let result = Err(error);
6969 self.remember_managed_spawn_attempt(
6970 durable_v2_key,
6971 request,
6972 result.clone(),
6973 accepted_at,
6974 );
6975 return result;
6976 }
6977 };
6978 let created = match create_git_worktree_with_timeout(
6979 &source_root,
6980 &lease.target_root,
6981 &lease.branch,
6982 Some(&base_commit),
6983 mutation_timeout_ms,
6984 ).await {
6985 Ok(created) => created,
6986 Err(error) => {
6987 self.reconcile_failed_allocation(&lease, &source_root).await;
6988 let result = Err(if Instant::now() >= deadline
6989 || error.message.contains("timed out")
6990 || error.message.contains("deadline")
6991 {
6992 failure(
6993 NodeFailureCode::SpawnDeadlineExceeded,
6994 "managed worktree creation exceeded the spawn deadline",
6995 )
6996 } else {
6997 managed_git_worktree_failure(error)
6998 });
6999 self.remember_managed_spawn_attempt(
7000 durable_v2_key,
7001 request,
7002 result.clone(),
7003 accepted_at,
7004 );
7005 return result;
7006 }
7007 };
7008 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7009 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7010 let result = Err(failure(
7011 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7012 "created worktree identity did not match its durable lease",
7013 ));
7014 self.remember_managed_spawn_attempt(
7015 durable_v2_key,
7016 request,
7017 result.clone(),
7018 accepted_at,
7019 );
7020 return result;
7021 }
7022 {
7023 let mut registry = self.managed_worktrees.lock()
7024 .unwrap_or_else(|poisoned| poisoned.into_inner());
7025 let current = registry.get_mut(&lease.lease_id)
7026 .expect("allocated managed worktree lease remains present");
7027 current.expected_head = Some(created.head.clone());
7028 current.state = ManagedWorktreeLeaseState::Ready;
7029 current.updated_at_unix_ms = unix_time_ms();
7030 }
7031 if let Err(error) = self.persist_state() {
7032 self.mark_managed_recovery_required(
7033 &lease.lease_id,
7034 ManagedWorktreeCleanupFailure::Backend,
7035 );
7036 let result = Err(persistence_failure(error));
7037 self.remember_managed_spawn_attempt(
7038 durable_v2_key,
7039 request,
7040 result.clone(),
7041 accepted_at,
7042 );
7043 return result;
7044 }
7045 let ready = self.managed_worktrees.lock()
7046 .unwrap_or_else(|poisoned| poisoned.into_inner())
7047 .get(&lease.lease_id)
7048 .expect("ready managed worktree lease remains present")
7049 .snapshot();
7050 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7051 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7052 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7053 let result = Err(error);
7054 self.remember_managed_spawn_attempt(
7055 durable_v2_key,
7056 request,
7057 result.clone(),
7058 accepted_at,
7059 );
7060 return result;
7061 }
7062 let spawn = self.spawn_session_with_deadline(
7063 lease.workspace_id.clone(),
7064 resolved.provider.clone(),
7065 resolved.mode,
7066 resolved.terminal_size,
7067 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7068 environment_profile.clone(),
7069 bundle.clone(),
7070 context.clone(),
7071 Some(lease.lease_id.clone()),
7072 Some(runtime_policy),
7073 SpawnRecordPolicy::Always,
7074 Some(deadline),
7075 &required_capabilities,
7076 None,
7077 resolved.approval_level,
7078 resolved.network_allowlist.clone(),
7079 resolved.browser_profile_id.clone(),
7080 ).await;
7081 let (session, effective_runtime_policy) = match spawn {
7082 Ok(spawned) => spawned,
7083 Err(error) => {
7084 if self
7085 .cleanup_managed_worktree(&lease.lease_id, true)
7086 .await
7087 .is_err()
7088 {
7089 self.mark_managed_recovery_required(
7090 &lease.lease_id,
7091 ManagedWorktreeCleanupFailure::Backend,
7092 );
7093 }
7094 let result = Err(error);
7095 self.remember_managed_spawn_attempt(
7096 durable_v2_key,
7097 request,
7098 result.clone(),
7099 accepted_at,
7100 );
7101 return result;
7102 }
7103 };
7104 let (record_id, raw_inventory_record_id) = {
7105 let mut bindings = self.session_bindings.lock()
7106 .unwrap_or_else(|poisoned| poisoned.into_inner());
7107 let binding = bindings.get_mut(&session.session.instance_id)
7108 .expect("successful spawn retains its session binding");
7109 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7110 managed_spawn_record_ownership(binding, effective_runtime_policy)
7111 };
7112 let bound = self.managed_worktrees.lock()
7113 .unwrap_or_else(|poisoned| poisoned.into_inner())
7114 .bind_session(
7115 &lease.lease_id,
7116 ManagedWorktreeSessionHolder {
7117 incarnation_id: self.incarnation_id,
7118 instance_id: session.session.instance_id,
7119 generation: session.session.generation,
7120 },
7121 record_id,
7122 unix_time_ms(),
7123 )
7124 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7125 let snapshot = match bound {
7126 Ok(snapshot) => snapshot,
7127 Err(error) => {
7128 if raw_inventory_record_id
7129 .as_ref()
7130 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7131 {
7132 self.mark_managed_recovery_required(
7133 &lease.lease_id,
7134 ManagedWorktreeCleanupFailure::Backend,
7135 );
7136 }
7137 let _ = self.remove_session(&session).await;
7138 self.mark_managed_recovery_required(
7139 &lease.lease_id,
7140 ManagedWorktreeCleanupFailure::Backend,
7141 );
7142 let result = Err(error);
7143 self.remember_managed_spawn_attempt(
7144 durable_v2_key,
7145 request,
7146 result.clone(),
7147 accepted_at,
7148 );
7149 return result;
7150 }
7151 };
7152 if let Err(error) = self.persist_state() {
7153 if raw_inventory_record_id
7154 .as_ref()
7155 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7156 {
7157 self.mark_managed_recovery_required(
7158 &lease.lease_id,
7159 ManagedWorktreeCleanupFailure::Backend,
7160 );
7161 }
7162 let _ = self.remove_session(&session).await;
7163 if self
7164 .cleanup_managed_worktree(&lease.lease_id, true)
7165 .await
7166 .is_err()
7167 {
7168 self.mark_managed_recovery_required(
7169 &lease.lease_id,
7170 ManagedWorktreeCleanupFailure::Backend,
7171 );
7172 }
7173 let result = Err(persistence_failure(error));
7174 self.remember_managed_spawn_attempt(
7175 durable_v2_key,
7176 request,
7177 result.clone(),
7178 accepted_at,
7179 );
7180 return result;
7181 }
7182 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7183 let receipt = managed_spawn_receipt(
7184 &resolved,
7185 self.incarnation_id,
7186 session,
7187 snapshot,
7188 environment_profile,
7189 bundle,
7190 context,
7191 );
7192 let result = Ok(receipt);
7193 self.remember_managed_spawn_attempt(
7194 durable_v2_key,
7195 request,
7196 result.clone(),
7197 accepted_at,
7198 );
7199 result
7200 }
7201
7202 async fn spawn_managed_worktree_v2(
7203 &self,
7204 request: ManagedWorktreeSpawnRequestV2,
7205 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7206 if request.spawn_spec.target.node_id != self.node_id {
7207 return Err(failure(
7208 NodeFailureCode::SpawnTargetMismatch,
7209 "spawn target does not match this node",
7210 ));
7211 }
7212 if request.spawn_spec.target.worktree_id.is_some() {
7213 return Err(failure(
7214 NodeFailureCode::InvalidRequest,
7215 "managed worktree spawn must not select a caller-provided worktree",
7216 ));
7217 }
7218 let request_digest = managed_spawn_request_digest_v2(&request)?;
7219 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7220 match replay {
7221 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7222 DurableManagedSpawnReplayDecision::PendingLinked => {
7223 return Err(failure(
7224 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7225 "managed worktree V2 reservation has an incomplete durable allocation",
7226 ));
7227 }
7228 DurableManagedSpawnReplayDecision::Reserved => {}
7229 }
7230 let validated = (|| {
7231 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7232 let profile = self.managed_profile(
7233 &resolved.target.workspace_id,
7234 &request.worktree_profile_id,
7235 )?;
7236 if profile.revision() != &request.expected_profile_revision {
7237 return Err(failure(
7238 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7239 "managed worktree profile revision does not match the loaded profile",
7240 ));
7241 }
7242 Ok(())
7243 })();
7244 match validated {
7245 Ok(()) => {}
7246 Err(error) => {
7247 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248 return Err(error);
7249 }
7250 }
7251 let legacy = ManagedWorktreeSpawnRequest {
7252 spawn_spec: request.spawn_spec.clone(),
7253 worktree_profile_id: request.worktree_profile_id.clone(),
7254 };
7255 match self
7256 .spawn_managed_worktree_inner(
7257 legacy,
7258 Some(&request.spawn_spec.idempotency_key),
7259 )
7260 .await
7261 {
7262 Ok(receipt) => {
7263 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7264 Ok(receipt)
7265 }
7266 Err(error) => {
7267 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7268 Err(error)
7269 }
7270 }
7271 }
7272
7273 fn managed_profile(
7274 &self,
7275 workspace_id: &WorkspaceId,
7276 profile_id: &WorktreeProfileId,
7277 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7278 if self.worktree_service_modes.get(workspace_id).copied()
7279 != Some(WorktreeServiceMode::Managed)
7280 {
7281 return Err(failure(
7282 NodeFailureCode::UnsupportedSpawnCapability,
7283 "workspace does not enable managed worktrees",
7284 ));
7285 }
7286 self.managed_worktree_profiles.get(workspace_id)
7287 .and_then(|profiles| profiles.get(profile_id))
7288 .cloned()
7289 .ok_or_else(|| failure(
7290 NodeFailureCode::InvalidRequest,
7291 "managed worktree profile is unavailable for this workspace",
7292 ))
7293 }
7294
7295 async fn reconcile_failed_allocation(
7296 &self,
7297 lease: &ManagedWorktreeLeaseRecord,
7298 source_root: &str,
7299 ) {
7300 match list_git_worktrees(source_root).await {
7301 Ok(worktrees) => {
7302 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7303 None => {
7304 match std::fs::symlink_metadata(&lease.target_root) {
7305 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7306 self.managed_worktrees.lock()
7307 .unwrap_or_else(|poisoned| poisoned.into_inner())
7308 .remove_unmutated(&lease.lease_id);
7309 let _ = self.persist_state();
7310 }
7311 _ => self.mark_managed_recovery_required(
7312 &lease.lease_id,
7313 ManagedWorktreeCleanupFailure::OwnershipConflict,
7314 ),
7315 }
7316 }
7317 Some(item) if exact_owned_worktree(
7318 lease,
7319 &item.path,
7320 item.branch.as_deref(),
7321 &item.head,
7322 ) => {
7323 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7324 }
7325 Some(_) => self.mark_managed_recovery_required(
7326 &lease.lease_id,
7327 ManagedWorktreeCleanupFailure::OwnershipConflict,
7328 ),
7329 }
7330 }
7331 Err(_) => self.mark_managed_recovery_required(
7332 &lease.lease_id,
7333 ManagedWorktreeCleanupFailure::Backend,
7334 ),
7335 }
7336 }
7337
7338 async fn reconcile_managed_worktrees(&self) {
7339 let leases = self.managed_worktrees.lock()
7340 .unwrap_or_else(|poisoned| poisoned.into_inner())
7341 .active_records()
7342 .cloned()
7343 .collect::<Vec<_>>();
7344 for lease in leases {
7345 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7346 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7347 _ => {
7348 self.mark_managed_recovery_required(
7349 &lease.lease_id,
7350 ManagedWorktreeCleanupFailure::OwnershipConflict,
7351 );
7352 continue;
7353 }
7354 };
7355 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7356 self.mark_managed_recovery_required(
7357 &lease.lease_id,
7358 ManagedWorktreeCleanupFailure::OwnershipConflict,
7359 );
7360 continue;
7361 }
7362 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7363 Ok(root) => root,
7364 Err(_) => {
7365 self.mark_managed_recovery_required(
7366 &lease.lease_id,
7367 ManagedWorktreeCleanupFailure::OwnershipConflict,
7368 );
7369 continue;
7370 }
7371 };
7372 let worktrees = match list_git_worktrees(&source_root).await {
7373 Ok(worktrees) => worktrees,
7374 Err(_) => {
7375 self.mark_managed_recovery_required(
7376 &lease.lease_id,
7377 ManagedWorktreeCleanupFailure::Backend,
7378 );
7379 continue;
7380 }
7381 };
7382 let listed = worktrees.iter()
7383 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7384 let Some(listed) = listed else {
7385 match std::fs::symlink_metadata(&lease.target_root) {
7386 Err(error) if error.kind() == io::ErrorKind::NotFound
7387 && lease.state == ManagedWorktreeLeaseState::Allocating
7388 && !lease.has_holders() => {
7389 let removed = self.managed_worktrees.lock()
7390 .unwrap_or_else(|poisoned| poisoned.into_inner())
7391 .tombstone(&lease.lease_id, unix_time_ms());
7392 if self.persist_state().is_ok() && removed.is_some() {
7393 self.publish(NodeEvent::ManagedWorktreeRemoved {
7394 lease_id: lease.lease_id.clone(),
7395 });
7396 }
7397 }
7398 _ => self.mark_managed_recovery_required(
7399 &lease.lease_id,
7400 ManagedWorktreeCleanupFailure::OwnershipConflict,
7401 ),
7402 }
7403 continue;
7404 };
7405 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7406 &lease,
7407 &listed.path,
7408 listed.branch.as_deref(),
7409 &listed.head,
7410 ) {
7411 self.mark_managed_recovery_required(
7412 &lease.lease_id,
7413 ManagedWorktreeCleanupFailure::OwnershipConflict,
7414 );
7415 continue;
7416 }
7417 if !self.workspaces.read()
7418 .unwrap_or_else(|poisoned| poisoned.into_inner())
7419 .contains_key(&lease.workspace_id)
7420 {
7421 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7422 self.mark_managed_recovery_required(
7423 &lease.lease_id,
7424 ManagedWorktreeCleanupFailure::Backend,
7425 );
7426 continue;
7427 }
7428 }
7429 let snapshot = {
7430 let mut registry = self.managed_worktrees.lock()
7431 .unwrap_or_else(|poisoned| poisoned.into_inner());
7432 let current = registry.get_mut(&lease.lease_id)
7433 .expect("reconciled managed lease remains present");
7434 current.expected_head = Some(listed.head.clone());
7435 current.cleanup_failure = None;
7436 current.state = if current.has_holders() {
7437 ManagedWorktreeLeaseState::InUse
7438 } else if current.retention == ManagedWorktreeRetention::Retain {
7439 ManagedWorktreeLeaseState::Retained
7440 } else {
7441 ManagedWorktreeLeaseState::Ready
7442 };
7443 current.updated_at_unix_ms = unix_time_ms();
7444 current.snapshot()
7445 };
7446 if self.persist_state().is_err() {
7447 self.mark_managed_recovery_required(
7448 &lease.lease_id,
7449 ManagedWorktreeCleanupFailure::Backend,
7450 );
7451 continue;
7452 }
7453 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7454 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7455 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7456 }
7457 }
7458 }
7459
7460 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7480 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7481 .session_records
7482 .lock()
7483 .unwrap_or_else(|poisoned| poisoned.into_inner())
7484 .records
7485 .values()
7486 .filter(|record| {
7487 record.state == ManagedSessionState::Live
7488 && record.exported_context.is_none()
7489 && record.provider_session.is_some()
7490 })
7491 .filter_map(|record| {
7492 record
7493 .active_session
7494 .clone()
7495 .map(|session| (record.record_id.clone(), session))
7496 })
7497 .collect();
7498 for (record_id, session) in candidates {
7499 let shared = Arc::clone(self);
7500 tokio::spawn(async move {
7501 shared
7502 .reconcile_context_pack_export_for_record(&record_id, session)
7503 .await;
7504 });
7505 }
7506 }
7507
7508 async fn reconcile_context_pack_export_for_record(
7527 &self,
7528 record_id: &SessionRecordId,
7529 session: SessionAddress,
7530 ) {
7531 let Ok(record) = self.record(record_id) else {
7532 return;
7533 };
7534 if !matches!(
7535 record.state,
7536 ManagedSessionState::Live | ManagedSessionState::Dormant
7537 ) || record.exported_context.is_some()
7538 || record.provider_session.is_none()
7539 {
7540 return;
7541 }
7542 let Ok(receipt) = self
7549 .export_context_pack_for_session_record(&record.record_id, &session, true)
7550 .await
7551 else {
7552 return;
7553 };
7554 self.set_exported_context(&record.record_id, receipt);
7555 }
7556
7557 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7562 let transaction = self
7563 .state_transaction
7564 .lock()
7565 .unwrap_or_else(|poisoned| poisoned.into_inner());
7566 let (previous, updated) = {
7567 let mut records = self
7568 .session_records
7569 .lock()
7570 .unwrap_or_else(|poisoned| poisoned.into_inner());
7571 let Some(record) = records.records.get_mut(record_id) else {
7572 return;
7573 };
7574 if record.exported_context.is_some() {
7575 return;
7576 }
7577 let previous = record.clone();
7578 record.exported_context = Some(receipt);
7579 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7580 (previous, record.clone())
7581 };
7582 if self.persist_state_locked().is_err() {
7583 self.session_records
7584 .lock()
7585 .unwrap_or_else(|poisoned| poisoned.into_inner())
7586 .records
7587 .insert(record_id.clone(), previous);
7588 return;
7589 }
7590 drop(transaction);
7591 self.publish_record(updated);
7592 }
7593
7594 fn managed_record_export_target_for_instance(
7604 &self,
7605 instance_id: AgentInstanceId,
7606 generation: SessionGeneration,
7607 ) -> Option<(SessionRecordId, SessionAddress)> {
7608 let bindings = self
7609 .session_bindings
7610 .lock()
7611 .unwrap_or_else(|poisoned| poisoned.into_inner());
7612 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7613 let record_id = binding.record_id.clone()?;
7614 Some((
7615 record_id,
7616 SessionAddress {
7617 workspace_id: binding.workspace_id.clone(),
7618 session: SessionKey { instance_id, generation },
7619 },
7620 ))
7621 }
7622
7623 fn mark_managed_recovery_required(
7624 &self,
7625 lease_id: &ManagedWorktreeLeaseId,
7626 failure_kind: ManagedWorktreeCleanupFailure,
7627 ) {
7628 let snapshot = {
7629 let mut registry = self.managed_worktrees.lock()
7630 .unwrap_or_else(|poisoned| poisoned.into_inner());
7631 let Some(lease) = registry.get_mut(lease_id) else { return };
7632 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7633 lease.cleanup_failure = Some(failure_kind);
7634 lease.updated_at_unix_ms = unix_time_ms();
7635 lease.snapshot()
7636 };
7637 let _ = self.persist_state();
7638 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7639 }
7640
7641 fn set_persistence_error(&self, error: Option<String>) {
7642 *self
7643 .persistence_error
7644 .write()
7645 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7646 .map(|message| session_registry::sanitized_persistence_summary(&message));
7647 }
7648
7649 fn persist_state(&self) -> io::Result<()> {
7650 let _transaction = self
7651 .state_transaction
7652 .lock()
7653 .unwrap_or_else(|poisoned| poisoned.into_inner());
7654 self.persist_state_locked()
7655 }
7656
7657 fn persist_state_locked(&self) -> io::Result<()> {
7658 let workspaces = self
7659 .workspaces
7660 .read()
7661 .unwrap_or_else(|poisoned| poisoned.into_inner())
7662 .clone();
7663 let records = self
7664 .session_records
7665 .lock()
7666 .unwrap_or_else(|poisoned| poisoned.into_inner())
7667 .records
7668 .values()
7669 .cloned()
7670 .collect::<Vec<_>>();
7671 let mut managed = self.managed_worktrees
7672 .lock()
7673 .unwrap_or_else(|poisoned| poisoned.into_inner());
7674 managed.reattach_record_holders(
7675 &managed_worktree_record_holders(&records),
7676 unix_time_ms(),
7677 );
7678 let materializations = self
7679 .materializations
7680 .lock()
7681 .unwrap_or_else(|poisoned| poisoned.into_inner())
7682 .values()
7683 .cloned()
7684 .collect::<Vec<_>>();
7685 let managed_spawn_replays = self.managed_spawn_replays
7686 .lock()
7687 .unwrap_or_else(|poisoned| poisoned.into_inner())
7688 .values()
7689 .cloned()
7690 .collect::<Vec<_>>();
7691 let result = session_registry::save_v10(
7692 self.state_path.as_deref(),
7693 &self.node_id,
7694 &workspaces,
7695 &records,
7696 &managed.records(),
7697 &managed.tombstones(),
7698 &materializations,
7699 &managed_spawn_replays,
7700 );
7701 match &result {
7702 Ok(warning) => self.set_persistence_error(warning.clone()),
7703 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7704 }
7705 result.map(|_| ())
7706 }
7707
7708 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7709 let _mutation_guard = self.mutation_gate.lock().await;
7710 self.begin_shutdown_locked().await
7711 }
7712
7713 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7714 if self.shutdown.swap(true, Ordering::AcqRel) {
7715 return Ok(());
7716 }
7717 SessionLifecycleHook::on_shutdown(self);
7718 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7719 let mut first_error = None;
7720 for session in &self.handle.snapshot().sessions {
7721 let remaining = deadline.saturating_duration_since(Instant::now());
7722 if remaining.is_zero() {
7723 first_error.get_or_insert_with(|| {
7724 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7725 });
7726 break;
7727 }
7728 if let Err(error) = self
7729 .dispatch_bounded(
7730 ControlCommand::Stop {
7731 instance_id: session.instance_id,
7732 force: true,
7733 },
7734 remaining,
7735 )
7736 .await
7737 {
7738 first_error.get_or_insert(error);
7739 }
7740 }
7741 self.shutdown_notify.notify_waiters();
7742 match first_error {
7743 Some(error) => Err(error),
7744 None => Ok(()),
7745 }
7746 }
7747
7748 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7749 let bindings = self
7750 .session_bindings
7751 .lock()
7752 .unwrap_or_else(|poisoned| poisoned.into_inner());
7753 if bindings.len() >= CONTROL_SESSIONS_MAX {
7754 Err(failure(
7755 NodeFailureCode::BackendBusy,
7756 "node session-binding capacity is full; remove an existing session first",
7757 ))
7758 } else {
7759 Ok(())
7760 }
7761 }
7762
7763 fn ensure_workspace_environment_profile_compatible(
7770 &self,
7771 workspace_id: &WorkspaceId,
7772 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7773 ) -> Result<(), NodeFailure> {
7774 let bindings = self
7775 .session_bindings
7776 .lock()
7777 .unwrap_or_else(|poisoned| poisoned.into_inner());
7778 for binding in bindings.values() {
7779 if &binding.workspace_id != workspace_id {
7780 continue;
7781 }
7782 if binding.environment_profile.as_ref() != environment_profile {
7783 return Err(failure(
7784 NodeFailureCode::WorkspaceBusy,
7785 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7786 ));
7787 }
7788 }
7789 Ok(())
7790 }
7791
7792 #[cfg(test)]
7793 fn bind_session(
7794 &self,
7795 instance_id: AgentInstanceId,
7796 workspace_id: WorkspaceId,
7797 generation: SessionGeneration,
7798 ) {
7799 self.bind_session_with_policy(
7800 &SessionAddress {
7801 workspace_id,
7802 session: SessionKey { instance_id, generation },
7803 },
7804 ProviderRuntimePolicy::raw_pty(),
7805 None,
7806 );
7807 }
7808
7809 #[cfg(test)]
7810 fn bind_session_with_policy(
7811 &self,
7812 address: &SessionAddress,
7813 runtime_policy: ProviderRuntimePolicy,
7814 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7815 ) {
7816 self.bind_session_with_materialization(
7817 address,
7818 runtime_policy,
7819 environment_profile,
7820 None,
7821 None,
7822 None,
7823 );
7824 }
7825
7826 fn bind_session_with_materialization(
7827 &self,
7828 address: &SessionAddress,
7829 runtime_policy: ProviderRuntimePolicy,
7830 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7831 bundle: Option<ResolvedBundleReceipt>,
7832 context: Option<ResolvedContextPackReceipt>,
7833 materialization_id: Option<MaterializationId>,
7834 ) {
7835 let mut bindings = self
7836 .session_bindings
7837 .lock()
7838 .unwrap_or_else(|poisoned| poisoned.into_inner());
7839 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7840 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7841 bindings.insert(address.session.instance_id, SessionBinding {
7842 workspace_id: address.workspace_id.clone(),
7843 generation: address.session.generation,
7844 runtime_policy,
7845 pending_resume: None,
7846 record_id: None,
7847 managed_worktree_lease_id: None,
7848 environment_profile,
7849 bundle,
7850 context,
7851 materialization_id,
7852 });
7853 }
7854
7855 #[cfg(test)]
7856 fn bind_managed_session(
7857 &self,
7858 address: &SessionAddress,
7859 record_id: SessionRecordId,
7860 runtime_policy: ProviderRuntimePolicy,
7861 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7862 ) {
7863 self.bind_managed_session_with_materialization(
7864 address,
7865 record_id,
7866 runtime_policy,
7867 environment_profile,
7868 None,
7869 None,
7870 None,
7871 );
7872 }
7873
7874 fn bind_managed_session_with_materialization(
7875 &self,
7876 address: &SessionAddress,
7877 record_id: SessionRecordId,
7878 runtime_policy: ProviderRuntimePolicy,
7879 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7880 bundle: Option<ResolvedBundleReceipt>,
7881 context: Option<ResolvedContextPackReceipt>,
7882 materialization_id: Option<MaterializationId>,
7883 ) {
7884 let mut bindings = self
7885 .session_bindings
7886 .lock()
7887 .unwrap_or_else(|poisoned| poisoned.into_inner());
7888 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7889 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7890 bindings.insert(
7891 address.session.instance_id,
7892 SessionBinding {
7893 workspace_id: address.workspace_id.clone(),
7894 generation: address.session.generation,
7895 runtime_policy,
7896 pending_resume: None,
7897 record_id: Some(record_id),
7898 managed_worktree_lease_id: None,
7899 environment_profile,
7900 bundle,
7901 context,
7902 materialization_id,
7903 },
7904 );
7905 }
7906
7907 #[cfg(test)]
7908 fn bind_spawn_session(
7909 &self,
7910 address: &SessionAddress,
7911 provider: AgentId,
7912 mode: SessionMode,
7913 runtime_policy: ProviderRuntimePolicy,
7914 record_policy: SpawnRecordPolicy,
7915 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7916 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7917 self.bind_spawn_session_with_materialization(
7918 address,
7919 provider,
7920 mode,
7921 runtime_policy,
7922 record_policy,
7923 environment_profile,
7924 None,
7925 None,
7926 None,
7927 )
7928 }
7929
7930 fn bind_spawn_session_with_materialization(
7931 &self,
7932 address: &SessionAddress,
7933 provider: AgentId,
7934 mode: SessionMode,
7935 runtime_policy: ProviderRuntimePolicy,
7936 record_policy: SpawnRecordPolicy,
7937 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7938 bundle: Option<ResolvedBundleReceipt>,
7939 context: Option<ResolvedContextPackReceipt>,
7940 materialization_id: Option<MaterializationId>,
7941 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7942 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7943 && !runtime_policy.provider_session_identity
7944 {
7945 self.bind_session_with_materialization(
7946 address,
7947 runtime_policy,
7948 environment_profile,
7949 bundle,
7950 context,
7951 materialization_id,
7952 );
7953 return Ok(None);
7954 }
7955 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7956 let record = self.new_record(
7957 address,
7958 provider,
7959 mode,
7960 active_record_state(runtime_policy.provider_session_identity, false),
7961 None,
7962 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7963 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7964 record_owns_lifecycle.then(|| context.clone()).flatten(),
7965 )?;
7966 let record_id = record.record_id.clone();
7967 let transaction = self
7968 .state_transaction
7969 .lock()
7970 .unwrap_or_else(|poisoned| poisoned.into_inner());
7971 self.insert_record(record.clone())?;
7972 let previous_materialization = if record_owns_lifecycle {
7973 if let Some(materialization_id) = materialization_id.as_ref() {
7974 let mut materializations = self
7975 .materializations
7976 .lock()
7977 .unwrap_or_else(|poisoned| poisoned.into_inner());
7978 let Some(ownership) = materializations.get_mut(materialization_id) else {
7979 drop(materializations);
7980 self.remove_record_memory(&record_id);
7981 return Err(failure(
7982 NodeFailureCode::BackendOperationFailed,
7983 "session environment ownership disappeared before binding",
7984 ));
7985 };
7986 let previous = ownership.clone();
7987 if ownership
7988 .transfer_to_record(record_id.clone(), unix_time_ms())
7989 .is_err()
7990 {
7991 drop(materializations);
7992 self.remove_record_memory(&record_id);
7993 return Err(failure(
7994 NodeFailureCode::BackendOperationFailed,
7995 "session environment ownership transfer failed",
7996 ));
7997 }
7998 Some(previous)
7999 } else {
8000 None
8001 }
8002 } else {
8003 None
8004 };
8005 self.bind_managed_session_with_materialization(
8006 address,
8007 record_id.clone(),
8008 runtime_policy,
8009 environment_profile,
8010 bundle,
8011 context,
8012 materialization_id.clone(),
8013 );
8014 if let Err(error) = self.persist_state_locked() {
8015 self.remove_binding(address);
8016 self.remove_record_memory(&record_id);
8017 if let (Some(materialization_id), Some(previous)) =
8018 (materialization_id.as_ref(), previous_materialization)
8019 {
8020 self.materializations
8021 .lock()
8022 .unwrap_or_else(|poisoned| poisoned.into_inner())
8023 .insert(materialization_id.clone(), previous);
8024 }
8025 return Err(persistence_failure(error));
8026 }
8027 drop(transaction);
8028 self.publish_record(record);
8029 Ok(Some(record_id))
8030 }
8031
8032 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8033 for _ in 0..8 {
8034 let bytes = random_nonce().map_err(|error| {
8035 failure(
8036 NodeFailureCode::BackendOperationFailed,
8037 &format!("session record identity generation failed: {error}"),
8038 )
8039 })?;
8040 let mut value = String::with_capacity(27);
8041 value.push_str("sr-");
8042 for byte in &bytes[..12] {
8043 use std::fmt::Write as _;
8044 let _ = write!(&mut value, "{byte:02x}");
8045 }
8046 let record_id = SessionRecordId::new(value).map_err(|error| {
8047 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8048 })?;
8049 let records = self
8050 .session_records
8051 .lock()
8052 .unwrap_or_else(|poisoned| poisoned.into_inner());
8053 if !records.records.contains_key(&record_id) {
8054 return Ok(record_id);
8055 }
8056 }
8057 Err(failure(
8058 NodeFailureCode::BackendBusy,
8059 "could not allocate a unique session record ID",
8060 ))
8061 }
8062
8063 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8064 for _ in 0..8 {
8065 let bytes = random_nonce().map_err(|error| {
8066 failure(
8067 NodeFailureCode::BackendOperationFailed,
8068 &format!("task identity generation failed: {error}"),
8069 )
8070 })?;
8071 let mut nonce = [0_u8; 12];
8072 nonce.copy_from_slice(&bytes[..12]);
8073 let task_id = TaskId::from_nonce(nonce);
8074 let records = self
8075 .session_records
8076 .lock()
8077 .unwrap_or_else(|poisoned| poisoned.into_inner());
8078 if !records.records.values().any(|record| {
8079 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8080 == Some(&task_id)
8081 }) {
8082 return Ok(task_id);
8083 }
8084 }
8085 Err(failure(
8086 NodeFailureCode::BackendBusy,
8087 "could not allocate a unique task ID",
8088 ))
8089 }
8090
8091 fn default_record_name(&self, provider: &AgentId) -> String {
8092 let records = self
8093 .session_records
8094 .lock()
8095 .unwrap_or_else(|poisoned| poisoned.into_inner());
8096 let ordinal = records
8097 .records
8098 .values()
8099 .filter(|record| &record.provider == provider)
8100 .count()
8101 .saturating_add(1);
8102 format!("{} #{ordinal}", provider.as_str())
8103 }
8104
8105 fn new_record(
8106 &self,
8107 address: &SessionAddress,
8108 provider: AgentId,
8109 mode: SessionMode,
8110 state: ManagedSessionState,
8111 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8112 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8113 bundle: Option<ResolvedBundleReceipt>,
8114 context: Option<ResolvedContextPackReceipt>,
8115 ) -> Result<ManagedSessionRecord, NodeFailure> {
8116 let canonical_root = self.workspace_root(&address.workspace_id)?;
8117 let now = unix_time_ms();
8118 Ok(ManagedSessionRecord {
8119 record_id: self.allocate_record_id()?,
8120 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8121 provider,
8122 mode,
8123 state,
8124 workspace_id: address.workspace_id.clone(),
8125 canonical_root: opaque_windows_path(canonical_root),
8126 provider_session,
8127 active_session: Some(address.clone()),
8128 environment_profile,
8129 bundle,
8130 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8131 context,
8132 exported_context: None,
8133 task_binding: None,
8134 created_at_unix_ms: now,
8135 updated_at_unix_ms: now,
8136 last_error: None,
8137 })
8138 }
8139
8140 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8141 let mut records = self
8142 .session_records
8143 .lock()
8144 .unwrap_or_else(|poisoned| poisoned.into_inner());
8145 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8146 return Err(failure(
8147 NodeFailureCode::BackendBusy,
8148 "managed session record capacity is full; forget an inactive record first",
8149 ));
8150 }
8151 if let Some(identity) = record.provider_session.as_ref() {
8152 if records.records.values().any(|candidate| {
8153 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8154 session_registry::same_provider_session(
8155 &candidate.provider,
8156 candidate_identity,
8157 &record.provider,
8158 identity,
8159 )
8160 })
8161 }) {
8162 return Err(failure(
8163 NodeFailureCode::SessionRecordConflict,
8164 "provider session is already represented by another managed record",
8165 ));
8166 }
8167 }
8168 match records.records.entry(record.record_id.clone()) {
8169 std::collections::btree_map::Entry::Vacant(entry) => {
8170 entry.insert(record);
8171 }
8172 std::collections::btree_map::Entry::Occupied(_) => {
8173 return Err(failure(
8174 NodeFailureCode::SessionRecordConflict,
8175 "session record ID collision",
8176 ));
8177 }
8178 }
8179 Ok(())
8180 }
8181
8182 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8183 self.session_records
8184 .lock()
8185 .unwrap_or_else(|poisoned| poisoned.into_inner())
8186 .records
8187 .get(record_id)
8188 .cloned()
8189 .ok_or_else(|| {
8190 failure(
8191 NodeFailureCode::UnknownSessionRecord,
8192 "managed session record does not exist",
8193 )
8194 })
8195 }
8196
8197 fn revalidate_session_record_preview(
8198 &self,
8199 expected: &ManagedSessionRecord,
8200 identity: &ProviderSessionIdentity,
8201 ) -> Result<(), NodeFailure> {
8202 let current = self.record(&expected.record_id)?;
8203 let current_root = self.workspace_root(¤t.workspace_id)?;
8204 if current.provider != expected.provider
8205 || current.workspace_id != expected.workspace_id
8206 || current.canonical_root != expected.canonical_root
8207 || current.provider_session.as_ref() != Some(identity)
8208 || !platform::roots_equal(
8209 ¤t_root,
8210 windows_path_text(¤t.canonical_root),
8211 )
8212 {
8213 return Err(failure(
8214 NodeFailureCode::SessionRecordConflict,
8215 "managed session changed while its preview was loading",
8216 ));
8217 }
8218 Ok(())
8219 }
8220
8221 fn revalidate_session_record_context_export(
8222 &self,
8223 expected: &ManagedSessionRecord,
8224 identity: Option<&ProviderSessionIdentity>,
8225 session: &SessionAddress,
8226 allow_clean_detachment: bool,
8227 ) -> Result<(), NodeFailure> {
8228 let current = self.record(&expected.record_id)?;
8229 let current_root = self.workspace_root(¤t.workspace_id)?;
8230 let snapshot = self.internal_session_snapshot(session)?;
8231 let binding = self
8232 .session_bindings
8233 .lock()
8234 .unwrap_or_else(|poisoned| poisoned.into_inner())
8235 .get(&session.session.instance_id)
8236 .cloned()
8237 .ok_or_else(|| {
8238 failure(
8239 NodeFailureCode::SessionRecordConflict,
8240 "managed session runtime binding disappeared during context export",
8241 )
8242 })?;
8243 if !session_record_context_export_binding_is_exact(
8244 expected,
8245 ¤t,
8246 identity,
8247 session,
8248 &binding,
8249 &snapshot.agent_id,
8250 ¤t_root,
8251 allow_clean_detachment,
8252 ) || !session_record_context_export_source_is_usable(
8253 ¤t,
8254 &snapshot.status,
8255 allow_clean_detachment,
8256 ) {
8257 return Err(failure(
8258 NodeFailureCode::SessionRecordConflict,
8259 "managed session binding changed during context export",
8260 ));
8261 }
8262 Ok(())
8263 }
8264
8265 fn revalidate_loaded_history(
8266 &self,
8267 session: &SessionAddress,
8268 candidate_id: &str,
8269 loaded: &HistorySessionRecord,
8270 ) -> Result<(), NodeFailure> {
8271 let snapshot = self.internal_session_snapshot(session)?;
8272 if snapshot.history.pending.is_some()
8273 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8274 || snapshot.history.loaded.as_ref() != Some(loaded)
8275 {
8276 return Err(failure(
8277 NodeFailureCode::SessionRecordConflict,
8278 "managed session history changed during context export",
8279 ));
8280 }
8281 Ok(())
8282 }
8283
8284 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8285 self.session_records
8286 .lock()
8287 .unwrap_or_else(|poisoned| poisoned.into_inner())
8288 .records
8289 .remove(record_id)
8290 }
8291
8292 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8293 if self.destroy_record_materialization(record_id)? {
8294 self.publish(NodeEvent::SessionRecordRemoved {
8295 record_id: record_id.clone(),
8296 });
8297 return Ok(());
8298 }
8299 let transaction = self
8300 .state_transaction
8301 .lock()
8302 .unwrap_or_else(|poisoned| poisoned.into_inner());
8303 let Some(removed) = self.remove_record_memory(record_id) else {
8304 return Ok(());
8305 };
8306 if let Err(error) = self.persist_state_locked() {
8307 let _ = self.insert_record(removed);
8308 return Err(persistence_failure(error));
8309 }
8310 drop(transaction);
8311 self.publish(NodeEvent::SessionRecordRemoved {
8312 record_id: record_id.clone(),
8313 });
8314 Ok(())
8315 }
8316
8317 fn publish_record(&self, record: ManagedSessionRecord) {
8318 self.publish(NodeEvent::SessionRecordUpserted { record });
8319 }
8320
8321 fn mark_record_error(
8322 &self,
8323 record_id: &SessionRecordId,
8324 message: &str,
8325 ) -> Result<(), NodeFailure> {
8326 let transaction = self
8327 .state_transaction
8328 .lock()
8329 .unwrap_or_else(|poisoned| poisoned.into_inner());
8330 let previous = {
8331 let mut records = self
8332 .session_records
8333 .lock()
8334 .unwrap_or_else(|poisoned| poisoned.into_inner());
8335 let Some(record) = records.records.get_mut(record_id) else {
8336 return Ok(());
8337 };
8338 let previous = record.clone();
8339 record.state = ManagedSessionState::Unavailable;
8340 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8341 record.updated_at_unix_ms = unix_time_ms();
8342 previous
8343 };
8344 if let Err(error) = self.persist_state_locked() {
8345 self.session_records
8346 .lock()
8347 .unwrap_or_else(|poisoned| poisoned.into_inner())
8348 .records
8349 .insert(record_id.clone(), previous);
8350 return Err(persistence_failure(error));
8351 }
8352 let record = self.record(record_id)?;
8353 drop(transaction);
8354 self.publish_record(record);
8355 Ok(())
8356 }
8357
8358 fn rename_session_record(
8359 &self,
8360 record_id: &SessionRecordId,
8361 display_name: String,
8362 ) -> Result<ManagedSessionRecord, NodeFailure> {
8363 validate_display_name(&display_name)
8364 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8365 let _transaction = self
8366 .state_transaction
8367 .lock()
8368 .unwrap_or_else(|poisoned| poisoned.into_inner());
8369 let previous = {
8370 let mut records = self
8371 .session_records
8372 .lock()
8373 .unwrap_or_else(|poisoned| poisoned.into_inner());
8374 let record = records.records.get_mut(record_id).ok_or_else(|| {
8375 failure(
8376 NodeFailureCode::UnknownSessionRecord,
8377 "managed session record does not exist",
8378 )
8379 })?;
8380 let previous = record.clone();
8381 record.display_name = display_name;
8382 record.updated_at_unix_ms = unix_time_ms();
8383 previous
8384 };
8385 if let Err(error) = self.persist_state_locked() {
8386 self.session_records
8387 .lock()
8388 .unwrap_or_else(|poisoned| poisoned.into_inner())
8389 .records
8390 .insert(record_id.clone(), previous);
8391 return Err(persistence_failure(error));
8392 }
8393 let updated = self.record(record_id)?;
8394 self.publish_record(updated.clone());
8395 Ok(updated)
8396 }
8397
8398 fn set_session_task(
8399 &self,
8400 record_id: &SessionRecordId,
8401 expected_revision: u64,
8402 target: SessionTaskTargetV1,
8403 ) -> Result<ManagedSessionRecord, NodeFailure> {
8404 let transaction = self
8405 .state_transaction
8406 .lock()
8407 .unwrap_or_else(|poisoned| poisoned.into_inner());
8408 let current = self.record(record_id)?;
8409 let current_task_id = current
8410 .task_binding
8411 .as_ref()
8412 .and_then(|binding| binding.task_id.as_ref());
8413 let current_revision = current
8414 .task_binding
8415 .as_ref()
8416 .map_or(0, |binding| binding.revision);
8417 if expected_revision != current_revision {
8418 return Err(failure(
8419 NodeFailureCode::SessionRecordConflict,
8420 "managed session task binding revision changed",
8421 ));
8422 }
8423 let exact = match &target {
8424 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8425 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8426 SessionTaskTargetV1::New => false,
8427 };
8428 if exact {
8429 return Ok(current);
8430 }
8431 let revision = current_revision.checked_add(1).ok_or_else(|| {
8432 failure(
8433 NodeFailureCode::SessionRecordConflict,
8434 "managed session task binding revision is exhausted",
8435 )
8436 })?;
8437 let task_id = match target {
8438 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8439 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8440 SessionTaskTargetV1::Clear => None,
8441 };
8442 let changed_at_unix_ms = unix_time_ms()
8443 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8444 failure(
8445 NodeFailureCode::SessionRecordConflict,
8446 "managed session record timestamp is exhausted",
8447 )
8448 })?);
8449 let updated = {
8450 let mut records = self
8451 .session_records
8452 .lock()
8453 .unwrap_or_else(|poisoned| poisoned.into_inner());
8454 let record = records.records.get_mut(record_id).ok_or_else(|| {
8455 failure(
8456 NodeFailureCode::UnknownSessionRecord,
8457 "managed session record does not exist",
8458 )
8459 })?;
8460 record.task_binding = Some(SessionTaskBindingV1 {
8461 revision,
8462 task_id,
8463 changed_at_unix_ms,
8464 });
8465 record.updated_at_unix_ms = changed_at_unix_ms;
8466 record.clone()
8467 };
8468 if let Err(error) = self.persist_state_locked() {
8469 self.session_records
8470 .lock()
8471 .unwrap_or_else(|poisoned| poisoned.into_inner())
8472 .records
8473 .insert(record_id.clone(), current);
8474 return Err(persistence_failure(error));
8475 }
8476 drop(transaction);
8477 self.publish_record(updated.clone());
8478 Ok(updated)
8479 }
8480
8481 fn index_provider_session(
8482 &self,
8483 workspace_id: WorkspaceId,
8484 provider: AgentId,
8485 identity: ProviderSessionIdentity,
8486 display_name: String,
8487 ) -> Result<ManagedSessionRecord, NodeFailure> {
8488 self.index_provider_session_with_policy(
8489 workspace_id,
8490 provider,
8491 identity,
8492 display_name,
8493 false,
8494 )
8495 }
8496
8497 fn index_provider_session_with_policy(
8498 &self,
8499 workspace_id: WorkspaceId,
8500 provider: AgentId,
8501 identity: ProviderSessionIdentity,
8502 display_name: String,
8503 allow_validated_transcript_path: bool,
8504 ) -> Result<ManagedSessionRecord, NodeFailure> {
8505 validate_display_name(&display_name)
8506 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8507 identity
8508 .validate()
8509 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8510 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8511 return Err(failure(
8512 NodeFailureCode::InvalidRequest,
8513 "provider session reference index v1 does not accept transcript paths",
8514 ));
8515 }
8516 let canonical_root = self.workspace_root(&workspace_id)?;
8517 if !self.enabled_providers.contains(&provider) {
8518 return Err(failure(
8519 NodeFailureCode::InvalidRequest,
8520 "provider is not enabled on this node",
8521 ));
8522 }
8523
8524 let transaction = self
8525 .state_transaction
8526 .lock()
8527 .unwrap_or_else(|poisoned| poisoned.into_inner());
8528 {
8529 let records = self
8530 .session_records
8531 .lock()
8532 .unwrap_or_else(|poisoned| poisoned.into_inner());
8533 if let Some(existing) = records.records.values().find(|candidate| {
8534 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8535 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8536 })
8537 }) {
8538 if existing.provider == provider && existing.workspace_id == workspace_id {
8539 return Ok(existing.clone());
8540 }
8541 return Err(failure(
8542 NodeFailureCode::SessionRecordConflict,
8543 "provider session reference conflicts with another provider or workspace",
8544 ));
8545 }
8546 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8547 return Err(failure(
8548 NodeFailureCode::BackendBusy,
8549 "managed session record capacity is full; forget an inactive record first",
8550 ));
8551 }
8552 }
8553
8554 let now = unix_time_ms();
8555 let record = ManagedSessionRecord {
8556 record_id: self.allocate_record_id()?,
8557 display_name,
8558 provider,
8559 mode: SessionMode::Pty,
8560 state: ManagedSessionState::Dormant,
8561 workspace_id,
8562 canonical_root: opaque_windows_path(canonical_root),
8563 provider_session: Some(identity),
8564 active_session: None,
8565 environment_profile: None,
8566 bundle: None,
8567 context_id: None,
8568 context: None,
8569 exported_context: None,
8570 task_binding: None,
8571 created_at_unix_ms: now,
8572 updated_at_unix_ms: now,
8573 last_error: None,
8574 };
8575 self.insert_record(record.clone())?;
8576 if let Err(error) = self.persist_state_locked() {
8577 self.remove_record_memory(&record.record_id);
8578 return Err(persistence_failure(error));
8579 }
8580 drop(transaction);
8581 self.publish_record(record.clone());
8582 Ok(record)
8583 }
8584
8585 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8586 self.session_bindings
8587 .lock()
8588 .unwrap_or_else(|poisoned| poisoned.into_inner())
8589 .iter()
8590 .find_map(|(instance_id, binding)| {
8591 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8592 workspace_id: binding.workspace_id.clone(),
8593 session: SessionKey {
8594 instance_id: *instance_id,
8595 generation: binding.generation,
8596 },
8597 })
8598 })
8599 }
8600
8601 async fn forget_session_record(
8602 &self,
8603 record_id: &SessionRecordId,
8604 ) -> Result<(), NodeFailure> {
8605 let record = self.record(record_id)?;
8606 if record.active_session.is_some()
8607 || matches!(
8608 record.state,
8609 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8610 )
8611 {
8612 return Err(failure(
8613 NodeFailureCode::SessionRecordBusy,
8614 "stop the managed session before forgetting it",
8615 ));
8616 }
8617 if let Some(address) = self.bound_address_for_record(record_id) {
8618 self.remove_session(&address).await?;
8619 }
8620 self.remove_managed_session_record(record_id, &record.workspace_id).await
8621 }
8622
8623 async fn remove_managed_session_record(
8638 &self,
8639 record_id: &SessionRecordId,
8640 workspace_id: &WorkspaceId,
8641 ) -> Result<(), NodeFailure> {
8642 let managed_lease_id = self.managed_worktrees.lock()
8643 .unwrap_or_else(|poisoned| poisoned.into_inner())
8644 .lease_for_workspace(workspace_id);
8645 if !self.destroy_record_materialization(record_id)? {
8646 let transaction = self
8647 .state_transaction
8648 .lock()
8649 .unwrap_or_else(|poisoned| poisoned.into_inner());
8650 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8651 failure(
8652 NodeFailureCode::UnknownSessionRecord,
8653 "managed session record does not exist",
8654 )
8655 })?;
8656 if let Err(error) = self.persist_state_locked() {
8657 let _ = self.insert_record(removed);
8658 return Err(persistence_failure(error));
8659 }
8660 drop(transaction);
8661 }
8662 self.publish(NodeEvent::SessionRecordRemoved {
8663 record_id: record_id.clone(),
8664 });
8665 if let Some(lease_id) = managed_lease_id {
8666 if let Some(snapshot) = self.managed_worktrees.lock()
8667 .unwrap_or_else(|poisoned| poisoned.into_inner())
8668 .get(&lease_id)
8669 .map(ManagedWorktreeLeaseRecord::snapshot)
8670 {
8671 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8672 }
8673 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8674 }
8675 Ok(())
8676 }
8677
8678 async fn retire_unavailable_session_records(&self) {
8691 let policy = self.session_record_retention;
8692 let age_enabled = policy.age_ms > 0;
8693 let keep_enabled = policy.keep_per_workspace > 0;
8694 if !age_enabled && !keep_enabled {
8695 return;
8696 }
8697 let now = unix_time_ms();
8698 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8699 self.session_records
8700 .lock()
8701 .unwrap_or_else(|poisoned| poisoned.into_inner())
8702 .records
8703 .values()
8704 .filter(|record| record.state == ManagedSessionState::Unavailable)
8705 .map(|record| {
8706 (
8707 record.record_id.clone(),
8708 record.workspace_id.clone(),
8709 record.provider.clone(),
8710 record.updated_at_unix_ms,
8711 )
8712 })
8713 .collect()
8714 };
8715 if candidates.is_empty() {
8716 return;
8717 }
8718 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8719 BTreeMap::new();
8720 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8721 by_workspace
8722 .entry(workspace_id)
8723 .or_default()
8724 .push((record_id, provider, updated_at_unix_ms));
8725 }
8726 let reason = if age_enabled && keep_enabled {
8727 "age+keep-n"
8728 } else if age_enabled {
8729 "age"
8730 } else {
8731 "keep-n"
8732 };
8733 let mut retired: u64 = 0;
8734 for (workspace_id, mut records) in by_workspace {
8735 records.sort_by(|left, right| right.2.cmp(&left.2));
8738 for (index, (record_id, provider, updated_at_unix_ms)) in
8739 records.into_iter().enumerate()
8740 {
8741 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8742 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8743 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8744 let should_retire = if age_enabled && keep_enabled {
8745 age_hit && keep_hit
8746 } else {
8747 age_hit || keep_hit
8748 };
8749 if !should_retire {
8750 continue;
8751 }
8752 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8753 Ok(()) => {
8754 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8755 retired += 1;
8756 tracing::info!(
8757 node_id = %self.node_id,
8758 record_id = %record_id,
8759 workspace_id = %workspace_id,
8760 provider = %provider,
8761 age_ms = record_age_ms,
8762 reason,
8763 "managed session record retired"
8764 );
8765 }
8766 Err(error) => tracing::warn!(
8767 node_id = %self.node_id,
8768 record_id = %record_id,
8769 workspace_id = %workspace_id,
8770 code = ?error.code,
8771 cause = %error.message,
8772 "session record retention sweep failed to retire a record"
8773 ),
8774 }
8775 }
8776 }
8777 if retired > 0 {
8778 let remaining_unavailable = self
8779 .session_records
8780 .lock()
8781 .unwrap_or_else(|poisoned| poisoned.into_inner())
8782 .records
8783 .values()
8784 .filter(|record| record.state == ManagedSessionState::Unavailable)
8785 .count();
8786 tracing::info!(
8787 node_id = %self.node_id,
8788 retired,
8789 remaining_unavailable,
8790 "session record retention sweep completed"
8791 );
8792 }
8793 }
8794
8795 async fn resume_session_record(
8796 &self,
8797 record_id: &SessionRecordId,
8798 terminal_size: gate4agent_types::TerminalSize,
8799 initial_prompt: Option<String>,
8800 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8801 let mut record = self.record(record_id)?;
8802 if record.mode == SessionMode::Acp
8805 && record.provider.as_str() == "claude"
8806 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8807 {
8808 record.mode = SessionMode::Pty;
8809 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8810 r.mode = SessionMode::Pty;
8811 }
8812 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8813 }
8814 if record.active_session.is_some()
8815 || matches!(
8816 record.state,
8817 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8818 )
8819 {
8820 return Err(failure(
8821 NodeFailureCode::SessionRecordBusy,
8822 "managed session already has a live runtime binding",
8823 ));
8824 }
8825 let identity = record.provider_session.clone().ok_or_else(|| {
8826 failure(
8827 NodeFailureCode::SessionRecordNotResumable,
8828 "managed session has no verified provider session identity",
8829 )
8830 })?;
8831 let working_directory = self.workspace_root(&record.workspace_id)?;
8832 if !platform::roots_equal(
8833 &working_directory,
8834 windows_path_text(&record.canonical_root),
8835 ) {
8836 return Err(failure(
8837 NodeFailureCode::SessionRecordConflict,
8838 "managed session workspace root changed; refusing to resume in another directory",
8839 ));
8840 }
8841 {
8842 let records = self
8843 .session_records
8844 .lock()
8845 .unwrap_or_else(|poisoned| poisoned.into_inner());
8846 if records.records.values().any(|candidate| {
8847 candidate.record_id != *record_id
8848 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8849 session_registry::same_provider_session(
8850 &candidate.provider,
8851 candidate_identity,
8852 &record.provider,
8853 &identity,
8854 )
8855 })
8856 && (candidate.active_session.is_some()
8857 || matches!(
8858 candidate.state,
8859 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8860 ))
8861 }) {
8862 return Err(failure(
8863 NodeFailureCode::SessionRecordConflict,
8864 "provider session is already active through another managed record",
8865 ));
8866 }
8867 }
8868 let runtime_requirement = if initial_prompt.is_some() {
8869 ProviderRuntimeRequirement::ResumeWithPrompt
8870 } else {
8871 ProviderRuntimeRequirement::Resume
8872 };
8873 let runtime_policy = self.admit_provider_runtime(
8874 &record.provider,
8875 runtime_requirement,
8876 )
8877 .await?;
8878 let working_directory = match self.m4a_accounts.as_ref() {
8880 Some(_) => {
8881 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8882 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8883 }
8884 None => working_directory,
8885 };
8886 let request = ResumeLaunchRequest {
8887 working_directory,
8888 terminal_size,
8889 initial_prompt,
8890 };
8891 request
8892 .validate()
8893 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8894 self.ensure_binding_capacity()?;
8895 let resolved_materialization = match self.resolve_record_materialization(
8896 record_id,
8897 &record.provider,
8898 record.mode,
8899 record.environment_profile.as_ref(),
8900 record.bundle.as_ref(),
8901 record.context.as_ref(),
8902 ) {
8903 Ok(materialization) => materialization,
8904 Err(error) => {
8905 let label = match error.code {
8906 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8907 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8908 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8909 _ => "environment-profile-unavailable",
8910 };
8911 self.mark_record_error(record_id, label)?;
8912 return Err(error);
8913 }
8914 };
8915 let (materialization_id, environment_overlay) = match resolved_materialization {
8916 Some((id, overlay)) => (Some(id), overlay),
8917 None => (None, None),
8918 };
8919 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8920 let environment_overlay = {
8922 let mut args: Vec<OsString> = Vec::new();
8923 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8924 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8925 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8926 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8927 }
8928 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8929 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8930 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8931 if let Some(sel) = options.as_ref() {
8932 args.push("--model".into()); args.push(sel.model.clone().into());
8933 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8934 args.push("--effort".into()); args.push(effort.to_owned().into());
8935 }
8936 }
8937 }
8938 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::journalled(record.provider.as_str(), record.mode == SessionMode::Acp) {
8939 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8940 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8941 instance_id: instance_id.0,
8942 provider: record.provider.as_str().to_owned(),
8943 workspace_id: record.workspace_id.as_str().to_owned(),
8944 provider_session_id: identity.id.clone(),
8945 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8946 cwd: String::new(),
8947 acp: record.mode == SessionMode::Acp,
8948 options,
8949 cols: 0,
8950 rows: 0,
8951 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8952 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8953 });
8954 }
8955 }
8956 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8957 };
8958 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8960 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8961 if let Some(old) = self.bound_address_for_record(record_id) {
8962 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8963 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8964 }
8965 }
8966 let session_key = self.m4a_session_key(instance_id);
8967 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8968 }
8969 _ => environment_overlay,
8970 };
8971 let mut environment_selection = match SpawnPreparer::select_environment(self,
8972 instance_id,
8973 &record.provider,
8974 record.mode,
8975 record.environment_profile.as_ref(),
8976 ) {
8977 Ok(selection) => selection,
8978 Err(error) => {
8979 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8980 return Err(error);
8981 }
8982 };
8983 let mut instance_overlay = environment_overlay
8984 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8985 .transpose()?
8986 .flatten();
8987 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8988 self.remove_session(&stale_address).await?;
8989 }
8990 let address = SessionAddress {
8991 workspace_id: record.workspace_id.clone(),
8992 session: SessionKey {
8993 instance_id,
8994 generation: SessionGeneration(1),
8995 },
8996 };
8997 let transaction = self
8998 .state_transaction
8999 .lock()
9000 .unwrap_or_else(|poisoned| poisoned.into_inner());
9001 let current = self.record(record_id)?;
9002 if current.active_session.is_some()
9003 || matches!(
9004 current.state,
9005 ManagedSessionState::Live | ManagedSessionState::IdentityPending
9006 )
9007 {
9008 return Err(failure(
9009 NodeFailureCode::SessionRecordBusy,
9010 "managed session became active while resume was being prepared",
9011 ));
9012 }
9013 self.bind_managed_session_with_materialization(
9014 &address,
9015 record_id.clone(),
9016 runtime_policy,
9017 record.environment_profile.clone(),
9018 record.bundle.clone(),
9019 record.context.clone(),
9020 materialization_id,
9021 );
9022 if let Some(selection) = environment_selection.take() {
9023 selection.retain();
9024 }
9025 if let Some(overlay) = instance_overlay.take() {
9026 overlay.retain();
9027 }
9028 drop(transaction);
9029
9030 let transport = match record.mode {
9031 SessionMode::Pty => TransportKind::Pty,
9032 SessionMode::Inline => TransportKind::Pipe,
9033 SessionMode::Acp => TransportKind::Acp,
9034 };
9035 let agent_id = record.provider.clone();
9036 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9037 if let Err(error) = self
9038 .dispatch_bounded(
9039 ControlCommand::Register {
9040 instance_id,
9041 agent_id,
9042 transport,
9043 },
9044 dispatch_timeout,
9045 )
9046 .await
9047 {
9048 if let Some(binding) = self.remove_binding(&address) {
9049 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9050 }
9051 return Err(error);
9052 }
9053 let command = self.prepare_command(ControlCommand::Resume {
9054 instance_id,
9055 target: ResumeTarget::ProviderSession { identity },
9056 runtime_policy,
9057 request,
9058 });
9059 let started_after = self.current_sequence();
9060 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9061 self.rollback_spawn(&address, dispatch_timeout).await?;
9062 return Err(error);
9063 }
9064 if let Err(error) = self.dispatch_envelope(command) {
9065 self.clear_armed_resume(&address);
9066 self.rollback_spawn(&address, dispatch_timeout).await?;
9067 return Err(error);
9068 }
9069 match self
9070 .wait_for_record_resume(&address, started_after)
9071 .await
9072 {
9073 Ok(settled_address) => {
9074 let managed_lease_id = {
9075 self.managed_worktrees.lock()
9076 .unwrap_or_else(|poisoned| poisoned.into_inner())
9077 .lease_for_workspace(&record.workspace_id)
9078 };
9079 if let Some(lease_id) = managed_lease_id {
9080 {
9081 let mut bindings = self.session_bindings.lock()
9082 .unwrap_or_else(|poisoned| poisoned.into_inner());
9083 let binding = bindings.get_mut(&settled_address.session.instance_id)
9084 .ok_or_else(|| failure(
9085 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9086 "resumed managed session lost its exact binding",
9087 ))?;
9088 if binding.generation != settled_address.session.generation {
9089 return Err(failure(
9090 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9091 "resumed managed session generation diverged",
9092 ));
9093 }
9094 binding.managed_worktree_lease_id = Some(lease_id.clone());
9095 }
9096 let snapshot = self.managed_worktrees.lock()
9097 .unwrap_or_else(|poisoned| poisoned.into_inner())
9098 .bind_session(
9099 &lease_id,
9100 ManagedWorktreeSessionHolder {
9101 incarnation_id: self.incarnation_id,
9102 instance_id: settled_address.session.instance_id,
9103 generation: settled_address.session.generation,
9104 },
9105 Some(record_id.clone()),
9106 unix_time_ms(),
9107 ).map_err(|message| failure(
9108 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9109 &message,
9110 ))?;
9111 if let Err(error) = self.persist_state() {
9112 let _ = self.remove_session(&settled_address).await;
9113 return Err(persistence_failure(error));
9114 }
9115 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9116 }
9117 Ok((self.record(record_id)?, settled_address))
9118 }
9119 Err(error) => {
9120 if matches!(
9121 error.code,
9122 NodeFailureCode::SessionRecordNotResumable
9123 | NodeFailureCode::BackendOperationFailed
9124 ) {
9125 if let Some(bound) = self.bound_address_for_record(record_id) {
9126 let _ = self.remove_session(&bound).await;
9127 }
9128 }
9129 Err(error)
9130 }
9131 }
9132 }
9133
9134 async fn wait_for_record_resume(
9135 &self,
9136 initial_address: &SessionAddress,
9137 after_sequence: u64,
9138 ) -> Result<SessionAddress, NodeFailure> {
9139 let deadline = Instant::now()
9140 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9141 let mut scan_after = after_sequence;
9142 loop {
9143 let events = self
9144 .history
9145 .lock()
9146 .unwrap_or_else(|poisoned| poisoned.into_inner())
9147 .events
9148 .iter()
9149 .filter(|event| event.sequence > scan_after)
9150 .cloned()
9151 .collect::<Vec<_>>();
9152 for envelope in events {
9153 scan_after = scan_after.max(envelope.sequence);
9154 let NodeEvent::Control { address, event } = envelope.event else {
9155 continue;
9156 };
9157 if address.session.instance_id != initial_address.session.instance_id {
9158 continue;
9159 }
9160 match event.event {
9161 ControlEventKind::Resumed { .. } => return Ok(address),
9162 ControlEventKind::ResumeDenied { reason } => {
9163 return Err(failure(
9164 NodeFailureCode::SessionRecordNotResumable,
9165 &reason,
9166 ));
9167 }
9168 ControlEventKind::ResumeFailed { message }
9169 | ControlEventKind::CommandRejected { message } => {
9170 return Err(failure(
9171 NodeFailureCode::BackendOperationFailed,
9172 &message,
9173 ));
9174 }
9175 _ => {}
9176 }
9177 }
9178 if Instant::now() >= deadline {
9179 return Err(failure(
9180 NodeFailureCode::BackendBusy,
9181 "managed resume did not settle before the bounded deadline",
9182 ));
9183 }
9184 sleep(Duration::from_millis(5)).await;
9185 }
9186 }
9187
9188 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9189 self.workspaces
9190 .read()
9191 .unwrap_or_else(|poisoned| poisoned.into_inner())
9192 .get(workspace_id)
9193 .cloned()
9194 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9195 }
9196
9197 fn workspace_roots(&self) -> Vec<PathBuf> {
9198 self.workspaces
9199 .read()
9200 .unwrap_or_else(|poisoned| poisoned.into_inner())
9201 .values()
9202 .map(PathBuf::from)
9203 .collect()
9204 }
9205
9206 async fn inspect_workspace(
9207 &self,
9208 workspace_id: WorkspaceId,
9209 ) -> Result<WorkspaceInspection, NodeFailure> {
9210 let _permit = self
9211 .inspection_slots
9212 .clone()
9213 .try_acquire_owned()
9214 .map_err(|_| {
9215 tracing::warn!(
9216 workspace_id = %workspace_id,
9217 "workspace inspection rejected: inspection capacity is busy",
9218 );
9219 failure(
9220 NodeFailureCode::BackendBusy,
9221 "workspace inspection capacity is busy",
9222 )
9223 })?;
9224 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9225 tracing::warn!(
9226 workspace_id = %workspace_id,
9227 code = ?error.code,
9228 "workspace inspection rejected: workspace is not registered",
9229 );
9230 error
9231 })?;
9232 let tree_root = canonical_root.clone();
9233 let started_at = Instant::now();
9234 let time_budget_ms = workspace_inspection_time_budget_ms();
9235 let entry_cap = workspace_inspection_entry_cap();
9236 let deadline = started_at + Duration::from_millis(time_budget_ms);
9237 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9238 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9239 })
9240 .await
9241 .map_err(|error| {
9242 tracing::warn!(
9243 workspace_id = %workspace_id,
9244 error = %error,
9245 "workspace inspection rejected: walk task failed",
9246 );
9247 failure(
9248 NodeFailureCode::BackendOperationFailed,
9249 &format!("workspace inspection task failed: {error}"),
9250 )
9251 })?;
9252 let (mut git, git_time_budget_exceeded) =
9253 inspect_git_workspace(&canonical_root, deadline).await;
9254 if !git.worktrees.is_empty() {
9255 let registered = self
9256 .workspaces
9257 .read()
9258 .unwrap_or_else(|poisoned| poisoned.into_inner())
9259 .iter()
9260 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9261 .collect::<Vec<_>>();
9262 for worktree in &mut git.worktrees {
9263 worktree.workspace_id = registered
9264 .iter()
9265 .find(|(_, root)| {
9266 worktree_paths_equal(root, windows_path_text(&worktree.path))
9267 })
9268 .map(|(workspace_id, _)| workspace_id.clone());
9269 }
9270 }
9271 if git.is_repository {
9272 git.managed_worktree = self
9273 .managed_worktrees
9274 .lock()
9275 .unwrap_or_else(|poisoned| poisoned.into_inner())
9276 .git_scope(&workspace_id, git.branch.as_deref());
9277 }
9278 let truncation = workspace_inspection_truncation(
9279 &walk_budget,
9280 git_time_budget_exceeded,
9281 started_at.elapsed(),
9282 );
9283 if let Some(truncation) = &truncation {
9284 tracing::info!(
9285 workspace_id = %workspace_id,
9286 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9287 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9288 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9289 entries_visited = truncation.entries_visited,
9290 elapsed_ms = truncation.elapsed_ms,
9291 "workspace inspection truncated by its inner budget",
9292 );
9293 }
9294 Ok(WorkspaceInspection {
9295 workspace_id,
9296 entries,
9297 tree_truncated,
9298 git,
9299 truncation,
9300 })
9301 }
9302
9303 async fn browse_host_directories(
9304 &self,
9305 directory: Option<OpaqueHostPath>,
9306 after: Option<OpaqueHostPath>,
9307 ) -> Result<HostDirectoryListing, NodeFailure> {
9308 let permit = self
9309 .inspection_slots
9310 .clone()
9311 .try_acquire_owned()
9312 .map_err(|_| {
9313 failure(
9314 NodeFailureCode::BackendBusy,
9315 "host directory browse capacity is busy",
9316 )
9317 })?;
9318 let task = tokio::task::spawn_blocking(move || {
9319 let _permit = permit;
9320 browse_host_directories(directory, after)
9321 });
9322 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9323 .await
9324 .map_err(|_| {
9325 failure(
9326 NodeFailureCode::HostDirectoryReadTimedOut,
9327 "host directory browse exceeded its bounded deadline",
9328 )
9329 })?
9330 .map_err(|_| {
9331 failure(
9332 NodeFailureCode::HostDirectoryReadFailed,
9333 "host directory browse task failed",
9334 )
9335 })?
9336 .map_err(host_directory_failure)
9337 }
9338
9339 async fn read_workspace_file(
9340 &self,
9341 workspace_id: WorkspaceId,
9342 path: RepositoryPath,
9343 ) -> Result<WorkspaceFileRead, NodeFailure> {
9344 let canonical_root = self.workspace_root(&workspace_id)?;
9345 let permit = self
9346 .inspection_slots
9347 .clone()
9348 .try_acquire_owned()
9349 .map_err(|_| {
9350 failure(
9351 NodeFailureCode::BackendBusy,
9352 "workspace file read capacity is busy",
9353 )
9354 })?;
9355 let path_to_read = path.clone();
9356 let task = tokio::task::spawn_blocking(move || {
9357 let _permit = permit;
9358 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9359 });
9360 let content = timeout(
9361 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9362 task,
9363 )
9364 .await
9365 .map_err(|_| {
9366 failure(
9367 NodeFailureCode::RepositoryFileReadTimedOut,
9368 "repository file read exceeded its bounded deadline",
9369 )
9370 })?
9371 .map_err(|_| {
9372 failure(
9373 NodeFailureCode::RepositoryFileReadFailed,
9374 "repository file read task failed",
9375 )
9376 })?
9377 .map_err(workspace_file_failure)?;
9378 let content = match content {
9379 WorkspaceFileBytes::Utf8(text) => {
9380 let revision = Some(workspace_file_revision(text.as_bytes()));
9381 return Ok(WorkspaceFileRead {
9382 workspace_id,
9383 path,
9384 content: WorkspaceFileContent::Utf8 {
9385 byte_len: u32::try_from(text.len())
9386 .expect("bounded workspace text length must fit u32"),
9387 text,
9388 },
9389 revision,
9390 });
9391 }
9392 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9393 WorkspaceFileContent::NonUtf8 {
9394 byte_len: u32::try_from(byte_length)
9395 .expect("bounded workspace file length must fit u32"),
9396 }
9397 }
9398 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9399 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9400 },
9401 };
9402 Ok(WorkspaceFileRead {
9403 workspace_id,
9404 path,
9405 content,
9406 revision: None,
9407 })
9408 }
9409
9410 async fn write_workspace_file(
9411 &self,
9412 workspace_id: WorkspaceId,
9413 path: RepositoryPath,
9414 expected_revision: WorkspaceFileRevision,
9415 text: String,
9416 ) -> Result<WorkspaceFileRead, NodeFailure> {
9417 let canonical_root = self.workspace_root(&workspace_id)?;
9418 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9419 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9420 })?;
9421 let path_to_write = path.clone();
9422 let expected = expected_revision.as_str().to_owned();
9423 let text_to_write = text.clone();
9424 let task = tokio::task::spawn_blocking(move || {
9425 let _permit = permit;
9426 write_workspace_file_to_disk(
9427 Path::new(&canonical_root),
9428 &path_to_write,
9429 &expected,
9430 &text_to_write,
9431 )
9432 });
9433 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9434 .await
9435 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9436 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9437 .map_err(workspace_file_write_failure)?;
9438 Ok(WorkspaceFileRead {
9439 workspace_id,
9440 path,
9441 content: WorkspaceFileContent::Utf8 {
9442 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9443 text,
9444 },
9445 revision: Some(WorkspaceFileRevision::new(revision)
9446 .expect("workspace writer returns a valid SHA-256 revision")),
9447 })
9448 }
9449
9450 async fn create_workspace_file(
9451 &self,
9452 workspace_id: WorkspaceId,
9453 path: RepositoryPath,
9454 ) -> Result<WorkspaceFileRead, NodeFailure> {
9455 let canonical_root = self.workspace_root(&workspace_id)?;
9456 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9457 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9458 })?;
9459 let path_to_create = path.clone();
9460 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9461 let task_commit_state = Arc::clone(&commit_state);
9462 let task = tokio::task::spawn_blocking(move || {
9463 let _permit = permit;
9464 create_workspace_file_on_disk(
9465 Path::new(&canonical_root),
9466 &path_to_create,
9467 &task_commit_state,
9468 )
9469 });
9470 let revision = settle_workspace_entry_create(
9471 task,
9472 commit_state,
9473 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9474 "repository file create exceeded its bounded deadline",
9475 "repository file create task failed",
9476 )
9477 .await?;
9478 Ok(WorkspaceFileRead {
9479 workspace_id,
9480 path,
9481 content: WorkspaceFileContent::Utf8 {
9482 text: String::new(),
9483 byte_len: 0,
9484 },
9485 revision: Some(WorkspaceFileRevision::new(revision)
9486 .expect("workspace creator returns a valid SHA-256 revision")),
9487 })
9488 }
9489
9490 async fn create_workspace_directory(
9491 &self,
9492 workspace_id: WorkspaceId,
9493 path: RepositoryPath,
9494 ) -> Result<WorkspaceEntry, NodeFailure> {
9495 let canonical_root = self.workspace_root(&workspace_id)?;
9496 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9497 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9498 })?;
9499 let path_to_create = path.clone();
9500 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9501 let task_commit_state = Arc::clone(&commit_state);
9502 let task = tokio::task::spawn_blocking(move || {
9503 let _permit = permit;
9504 create_workspace_directory_on_disk(
9505 Path::new(&canonical_root),
9506 &path_to_create,
9507 &task_commit_state,
9508 )
9509 });
9510 settle_workspace_entry_create(
9511 task,
9512 commit_state,
9513 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9514 "repository directory create exceeded its bounded deadline",
9515 "repository directory create task failed",
9516 )
9517 .await?;
9518 Ok(WorkspaceEntry {
9519 relative_path: path,
9520 kind: WorkspaceEntryKind::Directory,
9521 })
9522 }
9523
9524 async fn read_git_history(
9525 &self,
9526 workspace_id: WorkspaceId,
9527 path: Option<RepositoryPath>,
9528 before: Option<GitObjectId>,
9529 limit: u16,
9530 ) -> Result<GitHistoryPage, NodeFailure> {
9531 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9532 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9533 })?;
9534 let root = self.workspace_root(&workspace_id)?;
9535 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9536 }
9537
9538 async fn read_git_diff(
9539 &self,
9540 workspace_id: WorkspaceId,
9541 request: GitDiffRequest,
9542 ) -> Result<GitDiff, NodeFailure> {
9543 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9544 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9545 })?;
9546 let root = self.workspace_root(&workspace_id)?;
9547 read_git_diff_bounded(&root, request).await
9548 }
9549
9550 fn native_session_route_root(
9551 &self,
9552 route: &NativeSessionCatalogRoute,
9553 ) -> Result<Option<String>, NodeFailure> {
9554 route
9555 .validate()
9556 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9557 match route.scope {
9558 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9559 .workspace_id
9560 .as_ref()
9561 .map(|workspace_id| self.workspace_root(workspace_id))
9562 .transpose(),
9563 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9564 }
9565 }
9566
9567 fn project_native_session_entries(
9568 &self,
9569 route: &NativeSessionCatalogRoute,
9570 entries: Vec<ScopedNativeSessionCatalogEntry>,
9571 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9572 let current_root = route
9573 .workspace_id
9574 .as_ref()
9575 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9576 let records = self
9577 .session_records
9578 .lock()
9579 .unwrap_or_else(|poisoned| poisoned.into_inner());
9580 let mut seen_records = std::collections::HashSet::new();
9581 entries
9582 .into_iter()
9583 .map(|entry| {
9584 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9585 let matches = records
9586 .records
9587 .values()
9588 .filter(|record| {
9589 record.provider == route.provider
9590 && record.workspace_id == *workspace_id
9591 && current_root.as_deref().is_some_and(|root| {
9592 platform::roots_equal(
9593 root,
9594 windows_path_text(&record.canonical_root),
9595 )
9596 })
9597 && record.provider_session.as_ref().is_some_and(|identity| {
9598 session_registry::same_provider_session(
9599 &record.provider,
9600 identity,
9601 &route.provider,
9602 &entry.provider_session,
9603 )
9604 })
9605 })
9606 .map(|record| record.record_id.clone())
9607 .collect::<Vec<_>>();
9608 (matches.len() == 1
9609 && seen_records.insert(matches[0].clone()))
9610 .then(|| matches[0].clone())
9611 });
9612 let metadata = entry.metadata;
9613 crate::protocol::NativeSessionCatalogEntry {
9614 selection_id: metadata.selection_id,
9615 title: metadata.title,
9616 modified_at_unix_ms: metadata.modified_at_unix_ms,
9617 model: metadata.model,
9618 message_count: metadata.message_count,
9619 completed_turn_count: metadata.completed_turn_count,
9620 external_group: entry.external_group,
9621 record_id,
9622 }
9623 })
9624 .collect()
9625 }
9626
9627 async fn catalog_native_sessions(
9628 &self,
9629 route: NativeSessionCatalogRoute,
9630 limit: u16,
9631 ) -> Result<(
9632 Vec<crate::protocol::NativeSessionCatalogEntry>,
9633 crate::protocol::NativeSessionCatalogSummary,
9634 ), NodeFailure> {
9635 let canonical_root = self.native_session_route_root(&route)?;
9636 let registered_workspace_roots = self.workspace_roots();
9637 let provider = route.provider.clone();
9638 let scope = route.scope;
9639 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9640 failure(
9641 NodeFailureCode::InvalidRequest,
9642 "native session catalog is not configured",
9643 )
9644 })?;
9645 let _permit = Arc::clone(&self.inspection_slots)
9646 .try_acquire_owned()
9647 .map_err(|_| {
9648 failure(
9649 NodeFailureCode::BackendBusy,
9650 "native session catalog capacity is busy",
9651 )
9652 })?;
9653 let page = run_native_session_catalog_operation(
9654 catalog,
9655 "native session catalog",
9656 move |catalog| {
9657 catalog.catalog_initial_for_scope(
9658 &provider,
9659 scope,
9660 canonical_root.as_deref().map(Path::new),
9661 ®istered_workspace_roots,
9662 limit,
9663 )
9664 },
9665 )
9666 .await?;
9667 let summary = crate::protocol::NativeSessionCatalogSummary {
9668 catalog_revision: page.revision,
9669 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9670 recent_total_count: page.recent_total_count,
9671 older_total_count: page.older_total_count,
9672 recent_next_after_selection_id: page.next_after_selection_id,
9673 recent_has_more: page.remaining_count > 0,
9674 };
9675 Ok((self.project_native_session_entries(&route, page.entries), summary))
9676 }
9677
9678 async fn page_native_sessions(
9679 &self,
9680 route: NativeSessionCatalogRoute,
9681 window: crate::protocol::NativeSessionCatalogWindow,
9682 catalog_revision: u64,
9683 recent_cutoff_unix_ms: u64,
9684 after_selection_id: Option<String>,
9685 limit: u16,
9686 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9687 let canonical_root = self.native_session_route_root(&route)?;
9688 let registered_workspace_roots = self.workspace_roots();
9689 let provider = route.provider.clone();
9690 let scope = route.scope;
9691 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9692 failure(
9693 NodeFailureCode::InvalidRequest,
9694 "native session catalog is not configured",
9695 )
9696 })?;
9697 let _permit = Arc::clone(&self.inspection_slots)
9698 .try_acquire_owned()
9699 .map_err(|_| {
9700 failure(
9701 NodeFailureCode::BackendBusy,
9702 "native session catalog capacity is busy",
9703 )
9704 })?;
9705 let page = run_native_session_catalog_operation(
9706 catalog,
9707 "native session catalog page",
9708 move |catalog| {
9709 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9710 &provider,
9711 scope,
9712 canonical_root.as_deref().map(Path::new),
9713 ®istered_workspace_roots,
9714 window,
9715 catalog_revision,
9716 recent_cutoff_unix_ms,
9717 after_selection_id.as_deref(),
9718 limit,
9719 ))
9720 },
9721 )
9722 .await?;
9723 let page = page.map_err(|error| match error {
9724 NativeSessionCatalogError::StaleCatalog => failure(
9725 NodeFailureCode::StaleNativeSessionCatalog,
9726 "native session catalog revision or cursor is stale",
9727 ),
9728 _ => failure(
9729 NodeFailureCode::BackendOperationFailed,
9730 "native session catalog page failed",
9731 ),
9732 })?;
9733 let entries = self.project_native_session_entries(&route, page.entries);
9734 Ok(crate::protocol::NativeSessionCatalogPage {
9735 window: page.window,
9736 revision: page.revision,
9737 entries,
9738 next_after_selection_id: page.next_after_selection_id,
9739 remaining_count: page.remaining_count,
9740 has_more: page.remaining_count > 0,
9741 })
9742 }
9743
9744 async fn preview_native_session(
9745 &self,
9746 selection: NativeSessionSelection,
9747 message_limit: u16,
9748 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9749 selection
9750 .validate()
9751 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9752 let canonical_root = self.native_session_route_root(&selection.route)?;
9753 let registered_workspace_roots = self.workspace_roots();
9754 let provider = selection.route.provider.clone();
9755 let scope = selection.route.scope;
9756 let catalog_revision = selection.catalog_revision;
9757 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9758 let selection_id = selection.selection_id.clone();
9759 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9760 failure(
9761 NodeFailureCode::InvalidRequest,
9762 "native session preview is not configured",
9763 )
9764 })?;
9765 let _permit = Arc::clone(&self.inspection_slots)
9766 .try_acquire_owned()
9767 .map_err(|_| {
9768 failure(
9769 NodeFailureCode::BackendBusy,
9770 "native session preview capacity is busy",
9771 )
9772 })?;
9773 let preview = run_native_session_catalog_operation(
9774 catalog,
9775 "native session preview",
9776 move |catalog| {
9777 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9778 &provider,
9779 scope,
9780 canonical_root.as_deref().map(Path::new),
9781 ®istered_workspace_roots,
9782 catalog_revision,
9783 recent_cutoff_unix_ms,
9784 &selection_id,
9785 message_limit,
9786 ))
9787 },
9788 )
9789 .await?;
9790 preview
9791 .map(Into::into)
9792 .map_err(native_session_preview_failure)
9793 }
9794
9795 async fn index_native_session(
9796 &self,
9797 selection: NativeSessionSelection,
9798 display_name: String,
9799 ) -> Result<ManagedSessionRecord, NodeFailure> {
9800 selection
9801 .validate()
9802 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9803 if selection.route.scope
9804 != gate4agent_types::NativeSessionCatalogScope::Workspace
9805 {
9806 return Err(failure(
9807 NodeFailureCode::WorkspaceRegistrationRequired,
9808 "register the external project as a workspace before indexing or resuming it",
9809 ));
9810 }
9811 let workspace_id = selection
9812 .route
9813 .workspace_id
9814 .clone()
9815 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9816 let canonical_root = self
9817 .native_session_route_root(&selection.route)?
9818 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9819 let registered_workspace_roots = self.workspace_roots();
9820 let provider = selection.route.provider.clone();
9821 let catalog_revision = selection.catalog_revision;
9822 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9823 let selection_id = selection.selection_id.clone();
9824 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9825 failure(
9826 NodeFailureCode::InvalidRequest,
9827 "native session index is not configured",
9828 )
9829 })?;
9830 let _permit = Arc::clone(&self.inspection_slots)
9831 .try_acquire_owned()
9832 .map_err(|_| {
9833 failure(
9834 NodeFailureCode::BackendBusy,
9835 "native session index capacity is busy",
9836 )
9837 })?;
9838 let resolved = run_native_session_catalog_operation(
9839 catalog,
9840 "native session index resolution",
9841 move |catalog| {
9842 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9843 &provider,
9844 gate4agent_types::NativeSessionCatalogScope::Workspace,
9845 Some(Path::new(&canonical_root)),
9846 ®istered_workspace_roots,
9847 catalog_revision,
9848 recent_cutoff_unix_ms,
9849 &selection_id,
9850 ))
9851 },
9852 )
9853 .await?
9854 .map_err(native_session_preview_failure)?;
9855 self.index_provider_session_with_policy(
9856 workspace_id,
9857 selection.route.provider,
9858 resolved.identity,
9859 display_name,
9860 true,
9861 )
9862 }
9863
9864 async fn preview_native_session_by_session_id(
9865 &self,
9866 workspace_id: WorkspaceId,
9867 provider: AgentId,
9868 session_id: String,
9869 message_limit: u16,
9870 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9871 let canonical_root = self.workspace_root(&workspace_id)?;
9872 let registered_workspace_roots = self.workspace_roots();
9873 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9874 failure(
9875 NodeFailureCode::InvalidRequest,
9876 "native session preview is not configured",
9877 )
9878 })?;
9879 let _permit = Arc::clone(&self.inspection_slots)
9880 .try_acquire_owned()
9881 .map_err(|_| {
9882 failure(
9883 NodeFailureCode::BackendBusy,
9884 "native session preview capacity is busy",
9885 )
9886 })?;
9887 run_native_session_catalog_operation(
9888 catalog,
9889 "native session preview",
9890 move |catalog| {
9891 catalog.preview_session_id_for_workspace(
9892 &provider,
9893 Path::new(&canonical_root),
9894 ®istered_workspace_roots,
9895 &session_id,
9896 message_limit,
9897 )
9898 },
9899 )
9900 .await
9901 }
9902
9903 async fn register_workspace(
9904 &self,
9905 workspace_id: WorkspaceId,
9906 root: String,
9907 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9908 validate_workspace_request_root(&root)?;
9909 let canonicalize_workspace_id = workspace_id.clone();
9910 let workspace = tokio::task::spawn_blocking(move || {
9911 WorkspaceConfig::new(canonicalize_workspace_id, root)
9912 })
9913 .await
9914 .map_err(|error| {
9915 failure(
9916 NodeFailureCode::BackendOperationFailed,
9917 &format!("workspace canonicalization task failed: {error}"),
9918 )
9919 })?
9920 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9921 let transaction = self
9922 .state_transaction
9923 .lock()
9924 .unwrap_or_else(|poisoned| poisoned.into_inner());
9925 let mut workspaces = self
9926 .workspaces
9927 .write()
9928 .unwrap_or_else(|poisoned| poisoned.into_inner());
9929 if workspaces.contains_key(&workspace_id) {
9930 return Err(failure(
9931 NodeFailureCode::DuplicateWorkspaceId,
9932 "workspace ID is already registered",
9933 ));
9934 }
9935 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9936 platform::roots_equal(existing_root, workspace.canonical_root())
9937 }) {
9938 return Err(failure(
9939 NodeFailureCode::DuplicateWorkspaceRoot,
9940 &format!("workspace root is already registered as '{existing_id}'"),
9941 ));
9942 }
9943 {
9944 let records = self
9945 .session_records
9946 .lock()
9947 .unwrap_or_else(|poisoned| poisoned.into_inner());
9948 if let Some(record) = records.records.values().find(|record| {
9949 record.workspace_id == workspace_id
9950 && !record
9951 .canonical_root
9952 .as_utf8()
9953 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9954 }) {
9955 return Err(failure(
9956 NodeFailureCode::SessionRecordConflict,
9957 &format!(
9958 "workspace ID is retained by session record '{}' at another root",
9959 record.record_id,
9960 ),
9961 ));
9962 }
9963 }
9964 let snapshot = WorkspaceSnapshot {
9965 workspace_id: workspace_id.clone(),
9966 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9967 sessions: Vec::new(),
9968 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9969 managed_worktree_profiles: Some(WorktreeProfileInventory {
9970 profiles: Vec::new(),
9971 }),
9972 };
9973 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9974 drop(workspaces);
9975 let previous_records = {
9976 let mut records = self
9977 .session_records
9978 .lock()
9979 .unwrap_or_else(|poisoned| poisoned.into_inner());
9980 let previous = records.records.clone();
9981 for record in records.records.values_mut().filter(|record| {
9982 record.workspace_id == workspace_id
9983 && record
9984 .canonical_root
9985 .as_utf8()
9986 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9987 }) {
9988 if record.active_session.is_none() {
9989 record.state = detached_record_state(record);
9990 }
9991 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9992 record.last_error = None;
9993 }
9994 record.updated_at_unix_ms = unix_time_ms();
9995 }
9996 previous
9997 };
9998 if let Err(error) = self.persist_state_locked() {
9999 self.workspaces
10000 .write()
10001 .unwrap_or_else(|poisoned| poisoned.into_inner())
10002 .remove(&workspace_id);
10003 self.session_records
10004 .lock()
10005 .unwrap_or_else(|poisoned| poisoned.into_inner())
10006 .records = previous_records;
10007 return Err(persistence_failure(error));
10008 }
10009 let restored = self
10010 .session_records
10011 .lock()
10012 .unwrap_or_else(|poisoned| poisoned.into_inner())
10013 .records
10014 .values()
10015 .filter(|record| record.workspace_id == workspace_id)
10016 .cloned()
10017 .collect::<Vec<_>>();
10018 drop(transaction);
10019 self.publish(NodeEvent::WorkspaceAdded {
10020 workspace: snapshot.clone(),
10021 });
10022 for record in restored {
10023 self.publish_record(record);
10024 }
10025 Ok(snapshot)
10026 }
10027
10028 async fn create_standalone_workspace(
10029 &self,
10030 workspace_id: WorkspaceId,
10031 root: String,
10032 initial_branch: Option<String>,
10033 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10034 validate_workspace_request_root(&root)?;
10035 validate_git_revision("initial branch", initial_branch.as_deref())?;
10036 let preflight_workspace_id = workspace_id.clone();
10037 let preflight_root = root.clone();
10038 let canonical_candidate = tokio::task::spawn_blocking(move || {
10039 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10040 })
10041 .await
10042 .map_err(|_| failure(
10043 NodeFailureCode::BackendOperationFailed,
10044 "standalone workspace root preflight task failed",
10045 ))?
10046 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10047 {
10048 let workspaces = self
10049 .workspaces
10050 .read()
10051 .unwrap_or_else(|poisoned| poisoned.into_inner());
10052 if workspaces.contains_key(&workspace_id) {
10053 return Err(failure(
10054 NodeFailureCode::DuplicateWorkspaceId,
10055 "workspace ID is already registered",
10056 ));
10057 }
10058 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10059 platform::roots_equal(existing_root, &canonical_candidate)
10060 }) {
10061 return Err(failure(
10062 NodeFailureCode::DuplicateWorkspaceRoot,
10063 &format!("workspace root is already registered as '{existing_id}'"),
10064 ));
10065 }
10066 }
10067 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10068 .await
10069 .map_err(standalone_workspace_failure)?;
10070 prepared
10071 .verify_for_registration()
10072 .await
10073 .map_err(standalone_workspace_failure)?;
10074 match self
10075 .register_workspace(workspace_id, prepared.root().to_owned())
10076 .await
10077 {
10078 Ok(workspace) => Ok(workspace),
10079 Err(registration_error) => {
10080 if prepared.compensate_registration_failure().await {
10081 Err(registration_error)
10082 } else {
10083 Err(failure(
10084 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10085 "standalone workspace initialization succeeded but registration failed; recovery is required",
10086 ))
10087 }
10088 }
10089 }
10090 }
10091
10092 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10093 let bindings = self
10094 .session_bindings
10095 .lock()
10096 .unwrap_or_else(|poisoned| poisoned.into_inner());
10097 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10098 return Err(failure(
10099 NodeFailureCode::WorkspaceBusy,
10100 "workspace retains a bound session; remove every session before unregistering it",
10101 ));
10102 }
10103 drop(bindings);
10104 let transaction = self
10105 .state_transaction
10106 .lock()
10107 .unwrap_or_else(|poisoned| poisoned.into_inner());
10108 let mut workspaces = self
10109 .workspaces
10110 .write()
10111 .unwrap_or_else(|poisoned| poisoned.into_inner());
10112 if !workspaces.contains_key(workspace_id) {
10113 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10114 }
10115 if workspaces.len() == 1 {
10116 return Err(failure(
10117 NodeFailureCode::LastWorkspace,
10118 "node must retain at least one workspace",
10119 ));
10120 }
10121 let removed_root = workspaces
10122 .remove(workspace_id)
10123 .expect("workspace presence was validated");
10124 drop(workspaces);
10125 let previous_records = {
10126 let mut records = self
10127 .session_records
10128 .lock()
10129 .unwrap_or_else(|poisoned| poisoned.into_inner());
10130 let previous = records.records.clone();
10131 for record in records
10132 .records
10133 .values_mut()
10134 .filter(|record| &record.workspace_id == workspace_id)
10135 {
10136 record.active_session = None;
10137 record.state = ManagedSessionState::Unavailable;
10138 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10139 record.updated_at_unix_ms = unix_time_ms();
10140 }
10141 previous
10142 };
10143 if let Err(error) = self.persist_state_locked() {
10144 self.workspaces
10145 .write()
10146 .unwrap_or_else(|poisoned| poisoned.into_inner())
10147 .insert(workspace_id.clone(), removed_root);
10148 self.session_records
10149 .lock()
10150 .unwrap_or_else(|poisoned| poisoned.into_inner())
10151 .records = previous_records;
10152 return Err(persistence_failure(error));
10153 }
10154 let unavailable = self
10155 .session_records
10156 .lock()
10157 .unwrap_or_else(|poisoned| poisoned.into_inner())
10158 .records
10159 .values()
10160 .filter(|record| &record.workspace_id == workspace_id)
10161 .cloned()
10162 .collect::<Vec<_>>();
10163 drop(transaction);
10164 self.publish(NodeEvent::WorkspaceRemoved {
10165 workspace_id: workspace_id.clone(),
10166 });
10167 for record in unavailable {
10168 self.publish_record(record);
10169 }
10170 Ok(())
10171 }
10172
10173 async fn create_worktree(
10174 &self,
10175 source_workspace_id: WorkspaceId,
10176 workspace_id: WorkspaceId,
10177 target_root: String,
10178 branch: String,
10179 base: Option<String>,
10180 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10181 self.require_worktree_service(&source_workspace_id)?;
10182 validate_workspace_request_root(&target_root)?;
10183 validate_git_revision("worktree base", base.as_deref())?;
10184 let source_root = self.workspace_root(&source_workspace_id)?;
10185 {
10186 let workspaces = self
10187 .workspaces
10188 .read()
10189 .unwrap_or_else(|poisoned| poisoned.into_inner());
10190 if workspaces.contains_key(&workspace_id) {
10191 return Err(failure(
10192 NodeFailureCode::DuplicateWorkspaceId,
10193 "workspace ID is already registered",
10194 ));
10195 }
10196 if let Some((existing_id, _)) = workspaces
10197 .iter()
10198 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10199 {
10200 return Err(failure(
10201 NodeFailureCode::DuplicateWorkspaceRoot,
10202 &format!("workspace root is already registered as '{existing_id}'"),
10203 ));
10204 }
10205 }
10206 let mut worktree = create_git_worktree(
10207 &source_root,
10208 &target_root,
10209 &branch,
10210 base.as_deref(),
10211 )
10212 .await
10213 .map_err(git_worktree_failure)?;
10214 let workspace = match self
10215 .register_workspace(workspace_id, worktree.path.clone())
10216 .await
10217 {
10218 Ok(workspace) => workspace,
10219 Err(registration_error) => {
10220 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10221 return Err(failure(
10222 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10223 "worktree registration failed and compensation requires recovery",
10224 ));
10225 }
10226 return Err(failure(
10227 registration_error.code,
10228 ®istration_error.message,
10229 ));
10230 }
10231 };
10232 worktree.workspace_id = Some(workspace.workspace_id.clone());
10233 Ok((protocol_worktree(worktree), workspace))
10234 }
10235
10236 async fn remove_worktree(
10237 &self,
10238 source_workspace_id: WorkspaceId,
10239 target_root: String,
10240 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10241 self.require_worktree_service(&source_workspace_id)?;
10242 validate_workspace_request_root(&target_root)?;
10243 let source_root = self.workspace_root(&source_workspace_id)?;
10244 let target_root = normalize_worktree_removal_target(&target_root)
10245 .map_err(git_worktree_failure)?;
10246 let target = list_git_worktrees(&source_root)
10247 .await
10248 .map_err(git_worktree_failure)?
10249 .into_iter()
10250 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10251 .ok_or_else(|| {
10252 failure(
10253 NodeFailureCode::WorktreeProtected,
10254 "refusing to remove a path that is not in Git's worktree listing",
10255 )
10256 })?;
10257 let registered_workspace_id = {
10258 let workspaces = self
10259 .workspaces
10260 .read()
10261 .unwrap_or_else(|poisoned| poisoned.into_inner());
10262 let matched = workspaces
10263 .iter()
10264 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10265 .map(|(workspace_id, _)| workspace_id.clone());
10266 if matched.is_some() && workspaces.len() == 1 {
10267 return Err(failure(
10268 NodeFailureCode::LastWorkspace,
10269 "node must retain at least one workspace",
10270 ));
10271 }
10272 matched
10273 };
10274 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10275 let bindings = self
10276 .session_bindings
10277 .lock()
10278 .unwrap_or_else(|poisoned| poisoned.into_inner());
10279 if bindings
10280 .values()
10281 .any(|binding| &binding.workspace_id == workspace_id)
10282 {
10283 return Err(failure(
10284 NodeFailureCode::WorkspaceBusy,
10285 "worktree workspace retains a bound session; remove every session first",
10286 ));
10287 }
10288 }
10289 remove_git_worktree(&source_root, &target.path)
10290 .await
10291 .map_err(git_worktree_failure)?;
10292 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10293 self.unregister_workspace(workspace_id)
10294 .map_err(|error| failure(error.code, &error.message))?;
10295 }
10296 Ok(registered_workspace_id)
10297 }
10298
10299 async fn cleanup_managed_worktree(
10300 &self,
10301 lease_id: &ManagedWorktreeLeaseId,
10302 explicit: bool,
10303 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10304 let lease = self.managed_worktrees.lock()
10305 .unwrap_or_else(|poisoned| poisoned.into_inner())
10306 .get(lease_id)
10307 .cloned()
10308 .ok_or_else(|| failure(
10309 NodeFailureCode::UnknownManagedWorktreeLease,
10310 "managed worktree lease does not exist",
10311 ))?;
10312 if lease.state == ManagedWorktreeLeaseState::Removed {
10313 return Ok(lease.snapshot());
10314 }
10315 if lease.has_holders() {
10316 return Err(failure(
10317 NodeFailureCode::ManagedWorktreeBusy,
10318 "managed worktree lease still has a session or durable-record holder",
10319 ));
10320 }
10321 if self
10322 .materializations
10323 .lock()
10324 .unwrap_or_else(|poisoned| poisoned.into_inner())
10325 .values()
10326 .any(|record| record.managed_lease_id() == Some(lease_id))
10327 {
10328 return Err(failure(
10329 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10330 "managed worktree cleanup is blocked by session environment ownership",
10331 ));
10332 }
10333 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10334 let snapshot = {
10335 let mut registry = self.managed_worktrees.lock()
10336 .unwrap_or_else(|poisoned| poisoned.into_inner());
10337 let current = registry.get_mut(lease_id)
10338 .expect("managed worktree lease remains present");
10339 current.state = ManagedWorktreeLeaseState::Retained;
10340 current.cleanup_failure = None;
10341 current.updated_at_unix_ms = unix_time_ms();
10342 current.snapshot()
10343 };
10344 if self.persist_state().is_err() {
10345 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10346 }
10347 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10348 return Ok(snapshot);
10349 }
10350 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10351 if profile.revision() != &lease.profile_revision {
10352 self.mark_managed_recovery_required(
10353 lease_id,
10354 ManagedWorktreeCleanupFailure::OwnershipConflict,
10355 );
10356 return Err(failure(
10357 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10358 "managed worktree profile revision changed",
10359 ));
10360 }
10361 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10362 self.mark_managed_recovery_required(
10363 lease_id,
10364 ManagedWorktreeCleanupFailure::OwnershipConflict,
10365 );
10366 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10367 })?;
10368 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10369 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10370 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10371 managed_git_worktree_failure(error)
10372 })?;
10373 let target = listed.iter()
10374 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10375 if let Some(target) = target {
10376 if target.is_main || target.is_bare || !exact_owned_worktree(
10377 &lease,
10378 &target.path,
10379 target.branch.as_deref(),
10380 &target.head,
10381 ) {
10382 self.mark_managed_recovery_required(
10383 lease_id,
10384 ManagedWorktreeCleanupFailure::OwnershipConflict,
10385 );
10386 return Err(failure(
10387 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10388 "Git worktree identity no longer matches the managed lease",
10389 ));
10390 }
10391 if target.prunable {
10392 self.mark_managed_cleanup_blocked(
10393 lease_id,
10394 ManagedWorktreeCleanupFailure::Prunable,
10395 );
10396 return Err(failure(
10397 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10398 "managed worktree is prunable and requires recovery",
10399 ));
10400 }
10401 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10402 let failure_kind = match error.kind {
10403 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10404 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10405 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10406 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10407 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10408 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10409 };
10410 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10411 self.mark_managed_recovery_required(lease_id, failure_kind);
10412 } else {
10413 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10414 }
10415 return Err(managed_git_worktree_failure(error));
10416 }
10417 } else {
10418 match std::fs::symlink_metadata(&lease.target_root) {
10419 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10420 Ok(_) => {
10421 self.mark_managed_recovery_required(
10422 lease_id,
10423 ManagedWorktreeCleanupFailure::OwnershipConflict,
10424 );
10425 return Err(failure(
10426 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10427 "managed target exists but is not owned by Git",
10428 ));
10429 }
10430 Err(_) => {
10431 self.mark_managed_cleanup_blocked(
10432 lease_id,
10433 ManagedWorktreeCleanupFailure::Backend,
10434 );
10435 return Err(failure(
10436 NodeFailureCode::BackendOperationFailed,
10437 "managed target identity could not be inspected",
10438 ));
10439 }
10440 }
10441 }
10442 let registered = self.workspaces.read()
10443 .unwrap_or_else(|poisoned| poisoned.into_inner())
10444 .contains_key(&lease.workspace_id);
10445 if registered {
10446 self.unregister_workspace(&lease.workspace_id)?;
10447 }
10448 let removed = {
10449 self.managed_worktrees.lock()
10450 .unwrap_or_else(|poisoned| poisoned.into_inner())
10451 .tombstone(lease_id, unix_time_ms())
10452 .expect("managed worktree lease remains present during cleanup")
10453 };
10454 self.persist_state().map_err(persistence_failure)?;
10455 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10456 Ok(removed.snapshot())
10457 }
10458
10459 fn mark_managed_cleanup_blocked(
10460 &self,
10461 lease_id: &ManagedWorktreeLeaseId,
10462 failure_kind: ManagedWorktreeCleanupFailure,
10463 ) {
10464 let snapshot = {
10465 let mut registry = self.managed_worktrees.lock()
10466 .unwrap_or_else(|poisoned| poisoned.into_inner());
10467 let Some(lease) = registry.get_mut(lease_id) else { return };
10468 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10469 lease.cleanup_failure = Some(failure_kind);
10470 lease.updated_at_unix_ms = unix_time_ms();
10471 lease.snapshot()
10472 };
10473 let _ = self.persist_state();
10474 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10475 }
10476
10477 fn require_worktree_service(
10478 &self,
10479 source_workspace_id: &WorkspaceId,
10480 ) -> Result<(), NodeFailure> {
10481 if self.managed_worktrees.lock()
10482 .unwrap_or_else(|poisoned| poisoned.into_inner())
10483 .lease_for_workspace(source_workspace_id)
10484 .is_some()
10485 {
10486 return Err(failure(
10487 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10488 "managed worktree targets are internal-only workspaces",
10489 ));
10490 }
10491 match self
10492 .worktree_service_modes
10493 .get(source_workspace_id)
10494 .copied()
10495 .unwrap_or(WorktreeServiceMode::Manual)
10496 {
10497 WorktreeServiceMode::Manual => Ok(()),
10498 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10499 NodeFailureCode::UnsupportedSpawnCapability,
10500 "Git worktree service mode is unavailable in this node build",
10501 )),
10502 }
10503 }
10504
10505 fn managed_reservation_conflict(
10506 &self,
10507 workspace_id: Option<&WorkspaceId>,
10508 root: Option<&str>,
10509 ) -> bool {
10510 self.managed_worktrees.lock()
10511 .unwrap_or_else(|poisoned| poisoned.into_inner())
10512 .active_records()
10513 .any(|lease| {
10514 workspace_id.is_some_and(|workspace_id| {
10515 workspace_id == &lease.source_workspace_id
10516 || workspace_id == &lease.workspace_id
10517 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10518 })
10519 }
10520
10521 fn reject_managed_reservation(
10522 &self,
10523 workspace_id: Option<&WorkspaceId>,
10524 root: Option<&str>,
10525 ) -> Result<(), NodeFailure> {
10526 if self.managed_reservation_conflict(workspace_id, root) {
10527 Err(failure(
10528 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10529 "managed worktree ownership may only be mutated through its lease cleanup",
10530 ))
10531 } else {
10532 Ok(())
10533 }
10534 }
10535
10536 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10537 self.validate_address(address)?;
10538 self.workspace_root(&address.workspace_id)
10539 }
10540
10541 fn require_session_runtime_policy(
10575 &self,
10576 address: &SessionAddress,
10577 requirement: ProviderRuntimeRequirement,
10578 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10579 let runtime_policy = {
10580 let bindings = self
10581 .session_bindings
10582 .lock()
10583 .unwrap_or_else(|poisoned| poisoned.into_inner());
10584 let binding = bindings
10585 .get(&address.session.instance_id)
10586 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10587 if binding.workspace_id != address.workspace_id {
10588 return Err(failure(
10589 NodeFailureCode::SessionWorkspaceMismatch,
10590 "session belongs to another workspace",
10591 ));
10592 }
10593 if binding.generation != address.session.generation {
10594 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10595 }
10596 binding.runtime_policy
10597 };
10598 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10599 let snapshot = self.internal_session_snapshot(address)?;
10600 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10601 && snapshot.provider.lead_activity != ProviderActivity::Idle
10602 {
10603 return Err(failure(
10604 NodeFailureCode::TurnInFlight,
10605 "session already has a turn in flight; wait for it to complete before sending another prompt",
10606 ));
10607 }
10608 }
10609 require_policy(runtime_policy, false, false, requirement)
10610 .map(|()| runtime_policy)
10611 .map_err(|_| failure(
10612 NodeFailureCode::UnsupportedCapability,
10613 "session runtime policy does not admit this semantic operation",
10614 ))
10615 }
10616
10617 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10618 let mut bindings = self
10619 .session_bindings
10620 .lock()
10621 .unwrap_or_else(|poisoned| poisoned.into_inner());
10622 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10623 binding.workspace_id == address.workspace_id
10624 && binding.generation == address.session.generation
10625 }) {
10626 let removed = bindings.remove(&address.session.instance_id);
10627 self.clear_terminal_frame_watermark(address);
10628 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10629 return removed;
10630 }
10631 None
10632 }
10633
10634 fn cleanup_session_owned_materialization(
10635 &self,
10636 address: &SessionAddress,
10637 binding: &SessionBinding,
10638 ) -> Result<(), NodeFailure> {
10639 let Some(id) = binding.materialization_id.as_ref() else {
10640 return Ok(());
10641 };
10642 let owner = self
10643 .materializations
10644 .lock()
10645 .unwrap_or_else(|poisoned| poisoned.into_inner())
10646 .get(id)
10647 .map(|record| record.owner().clone());
10648 match owner {
10649 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10650 Some(MaterializationOwner::Session {
10651 incarnation_id,
10652 instance_id,
10653 generation,
10654 }) if incarnation_id == self.incarnation_id
10655 && instance_id == address.session.instance_id
10656 && generation == address.session.generation =>
10657 {
10658 self.cleanup_materialization(id)
10659 }
10660 Some(MaterializationOwner::Session { .. }) => Err(failure(
10661 NodeFailureCode::BackendOperationFailed,
10662 "session environment ownership requires recovery",
10663 )),
10664 }
10665 }
10666
10667 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10668 let mut watermarks = self
10669 .terminal_frame_watermarks
10670 .lock()
10671 .unwrap_or_else(|poisoned| poisoned.into_inner());
10672 if watermarks
10673 .get(&address.session.instance_id)
10674 .is_some_and(|(current, _)| current == address)
10675 {
10676 watermarks.remove(&address.session.instance_id);
10677 }
10678 }
10679
10680 #[cfg(test)]
10681 fn address_for(
10682 &self,
10683 instance_id: AgentInstanceId,
10684 generation: SessionGeneration,
10685 ) -> Option<SessionAddress> {
10686 let binding = self.session_bindings
10687 .lock()
10688 .unwrap_or_else(|poisoned| poisoned.into_inner())
10689 .get(&instance_id)
10690 .cloned()?;
10691 (binding.generation == generation).then_some(SessionAddress {
10692 workspace_id: binding.workspace_id,
10693 session: SessionKey { instance_id, generation },
10694 })
10695 }
10696
10697 fn arm_resume(
10698 &self,
10699 address: &SessionAddress,
10700 command_id: CommandId,
10701 runtime_policy: ProviderRuntimePolicy,
10702 ) -> Result<(), NodeFailure> {
10703 let mut bindings = self
10704 .session_bindings
10705 .lock()
10706 .unwrap_or_else(|poisoned| poisoned.into_inner());
10707 let binding = bindings
10708 .get_mut(&address.session.instance_id)
10709 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10710 if binding.workspace_id != address.workspace_id {
10711 return Err(failure(
10712 NodeFailureCode::SessionWorkspaceMismatch,
10713 "session belongs to another workspace",
10714 ));
10715 }
10716 if binding.generation != address.session.generation {
10717 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10718 }
10719 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10720 Ok(())
10721 }
10722
10723 fn clear_armed_resume(&self, address: &SessionAddress) {
10724 let mut bindings = self
10725 .session_bindings
10726 .lock()
10727 .unwrap_or_else(|poisoned| poisoned.into_inner());
10728 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10729 if binding.workspace_id == address.workspace_id
10730 && binding.generation == address.session.generation
10731 {
10732 binding.pending_resume = None;
10733 }
10734 }
10735 }
10736
10737 fn publish_control(&self, event: ControlEvent) {
10738 let revokes_harness_mcp = matches!(
10739 &event.event,
10740 ControlEventKind::Exited { .. }
10741 | ControlEventKind::Failed { .. }
10742 | ControlEventKind::Removed
10743 );
10744 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10745 let mut bindings = self
10746 .session_bindings
10747 .lock()
10748 .unwrap_or_else(|poisoned| poisoned.into_inner());
10749 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10750 return;
10751 };
10752 let previous_address = SessionAddress {
10753 workspace_id: binding.workspace_id.clone(),
10754 session: SessionKey {
10755 instance_id: event.instance_id,
10756 generation: binding.generation,
10757 },
10758 };
10759 let mut resume_command_rejected = false;
10760 if binding.generation == event.generation {
10761 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10762 let rejected_resume = matches!(
10763 &event.event,
10764 ControlEventKind::CommandRejected { .. }
10765 ) && event.command_id == Some(pending_command_id);
10766 let resume_failed = matches!(
10767 &event.event,
10768 ControlEventKind::ResumeDenied { .. }
10769 | ControlEventKind::ResumeFailed { .. }
10770 );
10771 if pending_generation == binding.generation
10772 && (rejected_resume || resume_failed)
10773 {
10774 resume_command_rejected = rejected_resume;
10775 binding.pending_resume = None;
10776 }
10777 }
10778 } else {
10779 let expected = binding.generation.0.checked_add(1);
10780 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10781 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10782 generation == binding.generation
10783 })
10784 || expected != Some(event.generation.0)
10785 || !authorized
10786 {
10787 return;
10788 }
10789 let (_, _, runtime_policy) = binding
10790 .pending_resume
10791 .expect("authorized resume retains its pending runtime policy");
10792 binding.generation = event.generation;
10793 binding.runtime_policy = runtime_policy;
10794 binding.pending_resume = None;
10795 }
10796 (
10797 SessionAddress {
10798 workspace_id: binding.workspace_id.clone(),
10799 session: SessionKey {
10800 instance_id: event.instance_id,
10801 generation: event.generation,
10802 },
10803 },
10804 (previous_address.session.generation != event.generation)
10805 .then_some(previous_address),
10806 binding.record_id.clone(),
10807 binding.runtime_policy.provider_session_identity,
10808 resume_command_rejected,
10809 )
10810 };
10811 if let Some(previous_address) = previous_address.as_ref() {
10812 self.clear_terminal_frame_watermark(previous_address);
10813 }
10814 if let Some(record_id) = record_id {
10815 self.reconcile_managed_record(
10816 &record_id,
10817 &address,
10818 &event,
10819 identity_admitted,
10820 resume_command_rejected,
10821 );
10822 }
10823 match &event.event {
10831 ControlEventKind::Running { process_id } => {
10832 if let Some(pid) = process_id {
10833 crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10834 }
10835 #[cfg(unix)]
10836 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10837 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10838 }
10839 tracing::info!(
10840 node_id = %self.node_id,
10841 workspace_id = %address.workspace_id,
10842 session = ?address.session,
10843 pid = ?process_id,
10844 "session process is running",
10845 );
10846 }
10847 ControlEventKind::Exited { exit_code, forced } => {
10848 crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10849 #[cfg(unix)]
10850 if let Some(proxy) = self.signer_proxy.get().cloned() {
10851 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10852 if proxy.has_session(&key) {
10853 std::thread::spawn(move || proxy.release(&key));
10855 }
10856 }
10857 tracing::info!(
10858 node_id = %self.node_id,
10859 workspace_id = %address.workspace_id,
10860 session = ?address.session,
10861 exit_code = ?exit_code,
10862 forced = forced,
10863 "session process exited",
10864 );
10865 }
10866 ControlEventKind::Failed { message } => {
10867 tracing::warn!(
10868 node_id = %self.node_id,
10869 workspace_id = %address.workspace_id,
10870 session = ?address.session,
10871 cause = %message,
10872 "session failed",
10873 );
10874 }
10875 ControlEventKind::CommandRejected { message } => {
10876 tracing::warn!(
10877 node_id = %self.node_id,
10878 workspace_id = %address.workspace_id,
10879 session = ?address.session,
10880 cause = %message,
10881 "session command rejected",
10882 );
10883 }
10884 ControlEventKind::Removed => {
10885 tracing::info!(
10886 node_id = %self.node_id,
10887 workspace_id = %address.workspace_id,
10888 session = ?address.session,
10889 "session removed",
10890 );
10891 }
10892 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10893 tracing::info!(
10894 node_id = %self.node_id,
10895 workspace_id = %address.workspace_id,
10896 session = ?address.session,
10897 provider_session = ?provider_session,
10898 "session resume authorized",
10899 );
10900 }
10901 ControlEventKind::Resumed { session: provider_session, process_id } => {
10902 tracing::info!(
10903 node_id = %self.node_id,
10904 workspace_id = %address.workspace_id,
10905 session = ?address.session,
10906 provider_session = ?provider_session,
10907 pid = ?process_id,
10908 "session resumed",
10909 );
10910 }
10911 ControlEventKind::ResumeDenied { reason } => {
10912 tracing::warn!(
10913 node_id = %self.node_id,
10914 workspace_id = %address.workspace_id,
10915 session = ?address.session,
10916 cause = %reason,
10917 "session resume denied",
10918 );
10919 }
10920 ControlEventKind::ResumeFailed { message } => {
10921 tracing::warn!(
10922 node_id = %self.node_id,
10923 workspace_id = %address.workspace_id,
10924 session = ?address.session,
10925 cause = %message,
10926 "session resume failed",
10927 );
10928 }
10929 _ => {}
10930 }
10931 self.apply_pending_acp_options(&address, &event);
10932 self.apply_acp_auto_permit(&address, &event);
10933 trace_acp_turn(&address, &event);
10934 self.observe_acp_provider_error(&address, &event);
10935 let stream_chunk = agent_stream_chunk(&event);
10936 self.publish(NodeEvent::Control {
10937 address: address.clone(),
10938 event,
10939 });
10940 if let Some(chunk) = stream_chunk {
10941 self.publish_agent_stream_chunk(address.clone(), chunk);
10942 }
10943 if revokes_harness_mcp {
10944 SessionLifecycleHook::on_session_ended(self, &address);
10945 }
10946 }
10947
10948 fn reconcile_managed_record(
10949 &self,
10950 record_id: &SessionRecordId,
10951 address: &SessionAddress,
10952 event: &ControlEvent,
10953 identity_admitted: bool,
10954 resume_command_rejected: bool,
10955 ) {
10956 let _transaction = self
10957 .state_transaction
10958 .lock()
10959 .unwrap_or_else(|poisoned| poisoned.into_inner());
10960 let observed_identity = match &event.event {
10961 ControlEventKind::ProviderEvent {
10962 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10963 ..
10964 } if identity_admitted => Some(identity.clone()),
10965 _ => None,
10966 };
10967 let identity_was_observed = observed_identity.is_some();
10968 let replacement_id = observed_identity
10969 .as_ref()
10970 .and_then(|_| self.allocate_record_id().ok());
10971 let mut upserts = Vec::new();
10972 let mut removals = Vec::new();
10973 let mut rebind = None;
10974 let original_records;
10975 let original_materializations = self
10976 .materializations
10977 .lock()
10978 .unwrap_or_else(|poisoned| poisoned.into_inner())
10979 .clone();
10980 {
10981 let mut records = self
10982 .session_records
10983 .lock()
10984 .unwrap_or_else(|poisoned| poisoned.into_inner());
10985 original_records = records.records.clone();
10986 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10987 return;
10988 };
10989 if let Some(identity) = observed_identity {
10990 let existing_id = records
10991 .records
10992 .values()
10993 .find(|record| {
10994 record.record_id != *record_id
10995 && record.provider_session.as_ref().is_some_and(|record_identity| {
10996 session_registry::same_provider_session(
10997 &record.provider,
10998 record_identity,
10999 ¤t_snapshot.provider,
11000 &identity,
11001 )
11002 })
11003 })
11004 .map(|record| record.record_id.clone());
11005 if let Some(existing_id) = existing_id {
11006 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11007 record.mode == current_snapshot.mode
11008 && record.workspace_id == current_snapshot.workspace_id
11009 && record.environment_profile == current_snapshot.environment_profile
11010 && record.bundle == current_snapshot.bundle
11011 && record.context_id == current_snapshot.context_id
11012 && record.context == current_snapshot.context
11013 && record
11014 .canonical_root
11015 .as_utf8()
11016 .zip(current_snapshot.canonical_root.as_utf8())
11017 .is_some_and(|(left, right)| platform::roots_equal(left, right))
11018 });
11019 if !scope_matches {
11020 if let Some(current) = records.records.get_mut(record_id) {
11021 current.state = ManagedSessionState::Unavailable;
11022 current.last_error =
11023 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11024 current.updated_at_unix_ms = unix_time_ms();
11025 upserts.push(current.clone());
11026 }
11027 } else {
11028 let existing_is_active = records
11029 .records
11030 .get(&existing_id)
11031 .is_some_and(|record| record.active_session.is_some());
11032 if existing_is_active {
11033 if let Some(current) = records.records.get_mut(record_id) {
11034 current.state = ManagedSessionState::Unavailable;
11035 current.last_error =
11036 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11037 current.updated_at_unix_ms = unix_time_ms();
11038 upserts.push(current.clone());
11039 }
11040 } else {
11041 if current_snapshot.provider_session.is_some() {
11042 if let Some(current) = records.records.get_mut(record_id) {
11043 current.active_session = None;
11044 current.state = ManagedSessionState::Dormant;
11045 current.updated_at_unix_ms = unix_time_ms();
11046 upserts.push(current.clone());
11047 }
11048 } else {
11049 records.records.remove(record_id);
11050 removals.push(record_id.clone());
11051 }
11052 if let Some(existing) = records.records.get_mut(&existing_id) {
11053 existing.provider_session = Some(identity.clone());
11054 existing.active_session = Some(address.clone());
11055 existing.state = ManagedSessionState::Live;
11056 existing.updated_at_unix_ms = unix_time_ms();
11057 existing.last_error = None;
11058 upserts.push(existing.clone());
11059 }
11060 rebind = Some(existing_id);
11061 }
11062 }
11063 } else if current_snapshot
11064 .provider_session
11065 .as_ref()
11066 .is_some_and(|current| {
11067 !session_registry::same_provider_session(
11068 ¤t_snapshot.provider,
11069 current,
11070 ¤t_snapshot.provider,
11071 &identity,
11072 )
11073 })
11074 {
11075 if let Some(current) = records.records.get_mut(record_id) {
11076 current.active_session = None;
11077 if current.bundle.is_some() || current.context.is_some() {
11078 let had_bundle = current.bundle.is_some();
11079 current.provider_session = None;
11080 current.bundle = None;
11081 current.context_id = None;
11082 current.context = None;
11083 current.state = ManagedSessionState::Unavailable;
11084 current.last_error = Some(if had_bundle {
11085 "bundle-unavailable".to_owned()
11086 } else {
11087 "context-unavailable".to_owned()
11088 });
11089 } else {
11090 current.state = ManagedSessionState::Dormant;
11091 }
11092 current.updated_at_unix_ms = unix_time_ms();
11093 upserts.push(current.clone());
11094 }
11095 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11096 if let Some(current) = records.records.get_mut(record_id) {
11097 current.state = ManagedSessionState::Unavailable;
11098 current.last_error =
11099 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11100 current.updated_at_unix_ms = unix_time_ms();
11101 upserts.push(current.clone());
11102 }
11103 } else if let Some(new_record_id) = replacement_id {
11104 let now = unix_time_ms();
11105 let replacement = ManagedSessionRecord {
11106 record_id: new_record_id.clone(),
11107 display_name: format!(
11108 "{} #{}",
11109 current_snapshot.provider.as_str(),
11110 records
11111 .records
11112 .values()
11113 .filter(|record| {
11114 record.provider == current_snapshot.provider
11115 })
11116 .count()
11117 .saturating_add(1),
11118 ),
11119 provider: current_snapshot.provider.clone(),
11120 mode: current_snapshot.mode,
11121 state: ManagedSessionState::Live,
11122 workspace_id: current_snapshot.workspace_id,
11123 canonical_root: current_snapshot.canonical_root,
11124 provider_session: Some(identity),
11125 active_session: Some(address.clone()),
11126 environment_profile: current_snapshot.environment_profile,
11127 bundle: current_snapshot.bundle,
11128 context_id: current_snapshot.context_id,
11129 context: current_snapshot.context,
11130 exported_context: None,
11136 task_binding: current_snapshot.task_binding,
11137 created_at_unix_ms: now,
11138 updated_at_unix_ms: now,
11139 last_error: None,
11140 };
11141 records
11142 .records
11143 .insert(new_record_id.clone(), replacement.clone());
11144 upserts.push(replacement);
11145 rebind = Some(new_record_id);
11146 } else if let Some(current) = records.records.get_mut(record_id) {
11147 current.state = ManagedSessionState::Unavailable;
11148 current.last_error =
11149 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11150 upserts.push(current.clone());
11151 }
11152 } else if let Some(current) = records.records.get_mut(record_id) {
11153 current.provider_session = Some(identity);
11154 current.active_session = Some(address.clone());
11155 current.state = ManagedSessionState::Live;
11156 current.updated_at_unix_ms = unix_time_ms();
11157 current.last_error = None;
11158 upserts.push(current.clone());
11159 }
11160 } else if let Some(current) = records.records.get_mut(record_id) {
11161 if resume_command_rejected {
11162 current.active_session = None;
11163 current.state = detached_record_state(current);
11164 current.updated_at_unix_ms = unix_time_ms();
11165 current.last_error =
11166 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11167 upserts.push(current.clone());
11168 } else {
11169 match &event.event {
11170 ControlEventKind::Running { .. }
11171 | ControlEventKind::ResumeAuthorized { .. }
11172 | ControlEventKind::Resumed { .. } => {
11173 current.active_session = Some(address.clone());
11174 current.state = active_record_state(
11175 identity_admitted,
11176 current.provider_session.is_some(),
11177 );
11178 current.updated_at_unix_ms = unix_time_ms();
11179 current.last_error = None;
11180 upserts.push(current.clone());
11181 }
11182 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11183 current.active_session = None;
11184 current.state = detached_record_state(current);
11185 current.updated_at_unix_ms = unix_time_ms();
11186 upserts.push(current.clone());
11187 }
11188 ControlEventKind::Failed { message }
11189 | ControlEventKind::ResumeFailed { message } => {
11190 current.active_session = None;
11191 current.state = detached_record_state(current);
11192 current.updated_at_unix_ms = unix_time_ms();
11193 current.last_error = Some(
11194 session_registry::sanitized_record_error_summary(message),
11195 );
11196 upserts.push(current.clone());
11197 }
11198 ControlEventKind::ResumeDenied { reason } => {
11199 current.active_session = None;
11200 current.state = detached_record_state(current);
11201 current.updated_at_unix_ms = unix_time_ms();
11202 current.last_error = Some(
11203 session_registry::sanitized_record_error_summary(reason),
11204 );
11205 upserts.push(current.clone());
11206 }
11207 _ => {}
11208 }
11209 }
11210 }
11211 }
11212 let materialization_owner_record = identity_was_observed.then(|| {
11213 rebind.clone().unwrap_or_else(|| record_id.clone())
11214 }).and_then(|candidate| {
11215 self.session_records
11216 .lock()
11217 .unwrap_or_else(|poisoned| poisoned.into_inner())
11218 .records
11219 .get(&candidate)
11220 .filter(|record| {
11221 record.provider_session.is_some()
11222 && record.active_session.as_ref() == Some(address)
11223 })
11224 .map(|_| candidate)
11225 });
11226 if let Some(owner_record_id) = materialization_owner_record {
11227 let materialization_id = self
11228 .session_bindings
11229 .lock()
11230 .unwrap_or_else(|poisoned| poisoned.into_inner())
11231 .get(&address.session.instance_id)
11232 .and_then(|binding| binding.materialization_id.clone());
11233 if let Some(materialization_id) = materialization_id {
11234 let mut transfer_failed = false;
11235 {
11236 let mut materializations = self
11237 .materializations
11238 .lock()
11239 .unwrap_or_else(|poisoned| poisoned.into_inner());
11240 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11241 match ownership.owner() {
11242 MaterializationOwner::Session {
11243 incarnation_id,
11244 instance_id,
11245 generation,
11246 } if *incarnation_id == self.incarnation_id
11247 && *instance_id == address.session.instance_id
11248 && *generation == address.session.generation =>
11249 {
11250 transfer_failed = ownership
11251 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11252 .is_err();
11253 }
11254 MaterializationOwner::Record { record_id }
11255 if record_id == &owner_record_id => {}
11256 MaterializationOwner::Record { record_id } => {
11257 let expected = record_id.clone();
11258 transfer_failed = ownership
11259 .transfer_record_owner(
11260 &expected,
11261 owner_record_id.clone(),
11262 unix_time_ms(),
11263 )
11264 .is_err();
11265 }
11266 _ => transfer_failed = true,
11267 }
11268 if transfer_failed {
11269 let _ = ownership.mark_recovery_required(unix_time_ms());
11270 }
11271 } else {
11272 transfer_failed = true;
11273 }
11274 }
11275 if transfer_failed {
11276 if let Some(record) = self
11277 .session_records
11278 .lock()
11279 .unwrap_or_else(|poisoned| poisoned.into_inner())
11280 .records
11281 .get_mut(&owner_record_id)
11282 {
11283 record.state = ManagedSessionState::Unavailable;
11284 record.last_error = Some("environment-profile-unavailable".to_owned());
11285 record.updated_at_unix_ms = unix_time_ms();
11286 upserts.push(record.clone());
11287 }
11288 }
11289 }
11290 }
11291 if let Some(new_record_id) = rebind.clone() {
11292 if let Some(binding) = self
11293 .session_bindings
11294 .lock()
11295 .unwrap_or_else(|poisoned| poisoned.into_inner())
11296 .get_mut(&address.session.instance_id)
11297 {
11298 binding.record_id = Some(new_record_id);
11299 }
11300 }
11301 if upserts.is_empty() && removals.is_empty() {
11302 return;
11303 }
11304 if self.persist_state_locked().is_err() {
11305 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11306 let mut records = self
11307 .session_records
11308 .lock()
11309 .unwrap_or_else(|poisoned| poisoned.into_inner());
11310 records.records = original_records;
11311 *self
11312 .materializations
11313 .lock()
11314 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11315 if let Some(current) = records.records.get_mut(record_id) {
11316 current.state = ManagedSessionState::Unavailable;
11317 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11318 current.updated_at_unix_ms = unix_time_ms();
11319 upserts = vec![current.clone()];
11320 } else {
11321 upserts.clear();
11322 }
11323 removals.clear();
11324 drop(records);
11325 if let Some(binding) = self
11326 .session_bindings
11327 .lock()
11328 .unwrap_or_else(|poisoned| poisoned.into_inner())
11329 .get_mut(&address.session.instance_id)
11330 {
11331 binding.record_id = Some(record_id.clone());
11332 }
11333 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11334 }
11335 for record in upserts {
11336 self.publish_record(record);
11337 }
11338 for record_id in removals {
11339 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11340 }
11341 }
11342
11343 fn snapshot(&self) -> NodeSnapshot {
11344 let control = self.handle.snapshot();
11345 let bindings = self
11346 .session_bindings
11347 .lock()
11348 .unwrap_or_else(|poisoned| poisoned.into_inner())
11349 .clone();
11350 let workspace_roots = self
11351 .workspaces
11352 .read()
11353 .unwrap_or_else(|poisoned| poisoned.into_inner())
11354 .clone();
11355 let environment_profiles = self
11356 .environment_profiles
11357 .read()
11358 .unwrap_or_else(|poisoned| poisoned.into_inner());
11359 let mut workspaces = workspace_roots
11360 .iter()
11361 .map(|(workspace_id, canonical_root)| {
11362 (
11363 workspace_id.clone(),
11364 WorkspaceSnapshot {
11365 workspace_id: workspace_id.clone(),
11366 canonical_root: opaque_windows_path(canonical_root.clone()),
11367 sessions: Vec::new(),
11368 worktree_service_mode: Some(
11369 self.worktree_service_modes
11370 .get(workspace_id)
11371 .copied()
11372 .unwrap_or(WorktreeServiceMode::Manual),
11373 ),
11374 managed_worktree_profiles: Some(WorktreeProfileInventory {
11375 profiles: self
11376 .managed_worktree_profiles
11377 .get(workspace_id)
11378 .into_iter()
11379 .flat_map(BTreeMap::values)
11380 .map(|profile| ManagedWorktreeProfileSummary {
11381 id: profile.profile_id().clone(),
11382 revision: profile.revision().clone(),
11383 retention: profile.retention(),
11384 })
11385 .collect(),
11386 }),
11387 },
11388 )
11389 })
11390 .collect::<BTreeMap<_, _>>();
11391 let mut agent_progress = Vec::new();
11392 for session in &control.sessions {
11393 let Some(binding) = bindings.get(&session.instance_id) else {
11394 continue;
11395 };
11396 if binding.generation != session.generation {
11397 continue;
11398 }
11399 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11400 let address = SessionAddress {
11401 workspace_id: binding.workspace_id.clone(),
11402 session: SessionKey {
11403 instance_id: session.instance_id,
11404 generation: session.generation,
11405 },
11406 };
11407 if let Some(progress) =
11408 agent_progress_from_provider_snapshot(address, &session.provider)
11409 {
11410 agent_progress.push(progress);
11411 }
11412 workspace.sessions.push(session.clone());
11413 }
11414 }
11415 NodeSnapshot {
11416 node_id: self.node_id.clone(),
11417 enabled_providers: self.enabled_providers.clone(),
11418 provider_runtime_statuses: {
11419 let updates = self
11420 .provider_runtime_status_updates
11421 .read()
11422 .unwrap_or_else(|poisoned| poisoned.into_inner());
11423 ProviderRuntimeStatuses::new(
11424 self.provider_runtime_statuses
11425 .iter()
11426 .filter(|status| !updates.contains_key(status.provider()))
11427 .cloned()
11428 .chain(updates.values().cloned()),
11429 )
11430 .expect("refreshed provider runtime inventory remains bounded and unique")
11431 },
11432 workspaces: workspaces.into_values().collect(),
11433 session_records: self
11434 .session_records
11435 .lock()
11436 .unwrap_or_else(|poisoned| poisoned.into_inner())
11437 .records
11438 .values()
11439 .cloned()
11440 .collect(),
11441 managed_worktrees: self.managed_worktrees
11442 .lock()
11443 .unwrap_or_else(|poisoned| poisoned.into_inner())
11444 .snapshots(),
11445 launch_inventory: Some(LaunchInventory {
11446 spawn_profiles: Some(
11447 self.spawn_profiles
11448 .iter()
11449 .filter_map(|profile| {
11450 resolved_spawn_profile_summary(profile, &environment_profiles)
11451 })
11452 .collect(),
11453 ),
11454 bundles: Some(
11455 self.bundle_catalog
11456 .read()
11457 .unwrap_or_else(|poisoned| poisoned.into_inner())
11458 .iter()
11459 .map(NodeBundle::receipt)
11460 .collect(),
11461 ),
11462 network_allowlists: {
11465 let catalog = self
11466 .network_allowlist_catalog
11467 .read()
11468 .unwrap_or_else(|poisoned| poisoned.into_inner());
11469 if catalog.is_empty() {
11470 None
11471 } else {
11472 Some(catalog.ids().cloned().collect())
11473 }
11474 },
11475 }),
11476 agent_progress,
11477 }
11478 }
11479
11480 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11481 assert!(
11482 !matches!(
11483 &event,
11484 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11485 ),
11486 "terminal frame and agent stream events must use their own dedicated live channel",
11487 );
11488 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11489 let sequence = history
11490 .last_sequence
11491 .checked_add(1)
11492 .expect("node event sequence exhausted");
11493 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11494 if let Some(evicted) = history.events.pop_front() {
11495 history.replay_floor_sequence = evicted
11496 .sequence
11497 .checked_add(1)
11498 .expect("node replay floor sequence exhausted");
11499 history.removed_record_providers.remove(&evicted.sequence);
11500 }
11501 }
11502 match &event {
11503 NodeEvent::HarnessMcpReadCall { .. } => {
11504 panic!("transient harness MCP calls must not enter durable event history")
11505 }
11506 NodeEvent::SessionRecordUpserted { record } => {
11507 history
11508 .record_providers
11509 .insert(record.record_id.clone(), record.provider.clone());
11510 }
11511 NodeEvent::SessionRecordRemoved { record_id } => {
11512 if let Some(provider) = history.record_providers.remove(record_id) {
11513 history.removed_record_providers.insert(sequence, provider);
11514 }
11515 }
11516 NodeEvent::ManagedWorktreeUpserted { .. }
11517 | NodeEvent::ManagedWorktreeRemoved { .. }
11518 | NodeEvent::Control { .. }
11519 | NodeEvent::ControllerChanged { .. }
11520 | NodeEvent::SessionRecordHistorySummarized { .. }
11521 | NodeEvent::WorkspaceAdded { .. }
11522 | NodeEvent::WorkspaceRemoved { .. }
11523 | NodeEvent::ResyncRequired { .. }
11524 | NodeEvent::AgentStream { .. }
11525 | NodeEvent::TerminalFrame { .. } => {}
11526 }
11527 let envelope = NodeEventEnvelope { sequence, event };
11528 history.events.push_back(envelope.clone());
11529 history.last_sequence = sequence;
11530 let _ = self.event_tx.send(envelope.clone());
11531 drop(history);
11532 envelope
11533 }
11534
11535 fn publish_transient(&self, event: NodeEvent) -> bool {
11546 assert!(event.requires_harness_mcp_proxy_capability());
11547 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11548 let envelope = NodeEventEnvelope { sequence: 0, event };
11549 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11550 Ok(_) => true,
11551 Err(broadcast::error::SendError(undelivered)) => {
11552 if let Some(NodeEventEnvelope {
11553 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11554 ..
11555 }) = undelivered.first() {
11556 tracing::warn!(
11562 call_id = call_id.as_str(),
11563 content_type = ?request.content_type,
11564 cause = "no-subscribers",
11565 "harness MCP proxy event could not be published",
11566 );
11567 }
11568 false
11569 }
11570 }
11571 }
11572
11573 fn publish_terminal_frames(&self) {
11574 let snapshot = self.handle.snapshot();
11575 let bindings = self
11576 .session_bindings
11577 .lock()
11578 .unwrap_or_else(|poisoned| poisoned.into_inner());
11579 let candidates = snapshot
11580 .sessions
11581 .iter()
11582 .filter_map(|session| {
11583 let binding = bindings.get(&session.instance_id)?;
11584 if binding.generation != session.generation {
11585 return None;
11586 }
11587 let frame = session.terminal_frame.clone()?;
11588 crate::session_signals::feed(
11589 binding.workspace_id.as_str(),
11590 session.instance_id.0,
11591 session.agent_id.as_str(),
11592 &frame.contents,
11593 );
11594 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11595 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11596 if let Some(act) = act {
11597 provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11598 }
11599 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11600 Some((
11601 SessionAddress {
11602 workspace_id: binding.workspace_id.clone(),
11603 session: SessionKey {
11604 instance_id: session.instance_id,
11605 generation: session.generation,
11606 },
11607 },
11608 frame,
11609 ))
11610 })
11611 .collect();
11612 drop(bindings);
11613 self.publish_terminal_frame_candidates(candidates);
11614 }
11615
11616 fn publish_terminal_frame_candidates(
11617 &self,
11618 candidates: Vec<(SessionAddress, TerminalFrame)>,
11619 ) {
11620 let mut watermarks = self
11621 .terminal_frame_watermarks
11622 .lock()
11623 .unwrap_or_else(|poisoned| poisoned.into_inner());
11624 let mut events = Vec::with_capacity(candidates.len());
11625 for (address, frame) in candidates {
11626 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11627 let advanced = watermarks
11628 .get(&address.session.instance_id)
11629 .map_or(true, |(current_address, sequence)| {
11630 current_address != &address || frame.sequence > *sequence
11631 });
11632 if !advanced {
11633 continue;
11634 }
11635 if is_first_frame {
11636 if self.controller_state().is_none() {
11650 tracing::warn!(
11651 node_id = %self.node_id,
11652 workspace_id = %address.workspace_id,
11653 session = ?address.session,
11654 terminal_size = ?frame.size,
11655 "session producing output with no controller attached",
11656 );
11657 } else {
11658 tracing::info!(
11659 node_id = %self.node_id,
11660 workspace_id = %address.workspace_id,
11661 session = ?address.session,
11662 terminal_size = ?frame.size,
11663 "session produced its first output",
11664 );
11665 }
11666 }
11667 watermarks.insert(
11668 address.session.instance_id,
11669 (address.clone(), frame.sequence),
11670 );
11671 if let Some((at, reported)) = self
11672 .pty_input_marks
11673 .lock()
11674 .unwrap_or_else(|poisoned| poisoned.into_inner())
11675 .get_mut(&address.session.instance_id)
11676 {
11677 if !*reported && at.elapsed() < Duration::from_secs(5) {
11679 *reported = true;
11680 tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11681 }
11682 }
11683 events.push(NodeEvent::TerminalFrame { address, frame });
11684 }
11685 drop(watermarks);
11686 if events.is_empty() {
11687 return;
11688 }
11689 let mut history = self
11690 .history
11691 .lock()
11692 .unwrap_or_else(|poisoned| poisoned.into_inner());
11693 let mut envelopes = Vec::with_capacity(events.len());
11694 for event in events {
11695 let sequence = history
11696 .last_sequence
11697 .checked_add(1)
11698 .expect("node event sequence exhausted");
11699 history.last_sequence = sequence;
11700 envelopes.push(NodeEventEnvelope { sequence, event });
11701 }
11702 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11703 drop(history);
11704 }
11705
11706 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11715 let mut history = self
11716 .history
11717 .lock()
11718 .unwrap_or_else(|poisoned| poisoned.into_inner());
11719 let sequence = history
11720 .last_sequence
11721 .checked_add(1)
11722 .expect("node event sequence exhausted");
11723 history.last_sequence = sequence;
11724 drop(history);
11725 tracing::debug!(
11733 source_sequence = chunk.source_sequence,
11734 kind = agent_stream_chunk_kind_label(&chunk.kind),
11735 envelope_sequence = sequence,
11736 "publishing agent stream chunk",
11737 );
11738 let envelope = NodeEventEnvelope {
11739 sequence,
11740 event: NodeEvent::AgentStream { address, chunk },
11741 };
11742 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11743 }
11744
11745 fn current_sequence(&self) -> u64 {
11746 self.history
11747 .lock()
11748 .unwrap_or_else(|poisoned| poisoned.into_inner())
11749 .last_sequence
11750 }
11751
11752 fn controller_state(&self) -> Option<ControllerState> {
11753 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11754 let lease = controller.as_ref().copied()?;
11755 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11756 if remaining.is_zero() {
11757 *controller = None;
11758 return None;
11759 }
11760 Some(ControllerState {
11761 connection_id: lease.connection_id,
11762 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11763 })
11764 }
11765
11766 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11767 if role != ClientRole::Operator {
11768 tracing::warn!(
11769 node_id = %self.node_id,
11770 connection_id,
11771 "controller lease rejected: observer connections cannot acquire control",
11772 );
11773 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11774 }
11775 let lease_ms = if lease_ms == 0 {
11776 DEFAULT_CONTROLLER_LEASE_MS
11777 } else {
11778 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11779 };
11780 let now = Instant::now();
11781 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11782 if let Some(active) = controller.as_ref() {
11783 if active.connection_id != connection_id && active.expires_at > now {
11784 tracing::warn!(
11785 node_id = %self.node_id,
11786 connection_id,
11787 holder_connection_id = active.connection_id,
11788 "controller lease rejected: another operator holds it",
11789 );
11790 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11791 }
11792 }
11793 let is_new_attach = match controller.as_ref() {
11800 Some(active) => active.connection_id != connection_id,
11801 None => true,
11802 };
11803 *controller = Some(ControllerLease {
11804 connection_id,
11805 expires_at: now + Duration::from_millis(lease_ms),
11806 });
11807 drop(controller);
11808 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11809 if is_new_attach {
11810 tracing::info!(
11811 node_id = %self.node_id,
11812 connection_id,
11813 lease_ms,
11814 "controller lease acquired",
11815 );
11816 } else {
11817 tracing::debug!(
11818 node_id = %self.node_id,
11819 connection_id,
11820 lease_ms,
11821 "controller lease renewed",
11822 );
11823 }
11824 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11825 Ok(state)
11826 }
11827
11828 fn release_controller(&self, connection_id: u64) -> bool {
11829 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11830 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11831 if released {
11832 *controller = None;
11833 }
11834 drop(controller);
11835 if released {
11836 tracing::info!(
11837 node_id = %self.node_id,
11838 connection_id,
11839 "controller lease released",
11840 );
11841 self.publish(NodeEvent::ControllerChanged { controller: None });
11842 }
11843 released
11844 }
11845
11846 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11855 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11856 return;
11857 };
11858 let id = address.session.instance_id;
11859 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11860 return;
11861 };
11862 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11863 .iter()
11864 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11865 .collect();
11866 let (writes, set_model, effort_gap) =
11867 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11868 if set_model {
11869 if let Some(model_id) = model.clone() {
11870 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11871 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11872 }
11873 }
11874 }
11875 for (option_id, value_json) in writes {
11876 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11877 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11878 }
11879 }
11880 if effort_gap {
11881 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11882 }
11883 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11884 }
11885
11886 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11889 let id = address.session.instance_id;
11890 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11891 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11892 crate::auto_permit::forget_acp(id.0);
11893 return;
11894 }
11895 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11896 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11897 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11898 }
11899 match provider_event {
11900 ProviderEvent::ModeChanged { mode_id, available } => {
11901 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11902 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11903 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11904 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11905 }
11906 }
11907 }
11908 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11909 match self.dispatch(ControlCommand::ResolveInteraction {
11910 instance_id: id,
11911 generation: address.session.generation,
11912 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11913 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11914 }) {
11915 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11916 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11917 }
11918 }
11919 _ => {}
11922 }
11923 }
11924
11925 fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
11928 let id = address.session.instance_id.0;
11929 let found = match &event.event {
11930 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11931 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
11932 return;
11933 }
11934 ControlEventKind::ProviderEvent { event: e, .. } => match e {
11935 ProviderEvent::TurnStarted { prompt } => {
11936 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
11937 return;
11938 }
11939 ProviderEvent::SessionEnded { is_error: false, .. } => {
11940 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ok(id);
11941 return;
11942 }
11943 ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => crate::provider_error::classify_message(message, vendor_code.as_deref()),
11944 ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
11945 _ => return,
11946 },
11947 _ => return,
11948 };
11949 let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11950 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
11951 if let Some(act) = act {
11952 provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11953 }
11954 }
11955
11956 fn run_due_provider_retries(&self) {
11958 let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
11959 for (id, how, attempt) in due {
11960 let instance_id = AgentInstanceId(id);
11961 let r = match how {
11962 crate::provider_error::Retry::Pty => self
11963 .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
11964 .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
11965 crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
11966 instance_id,
11967 action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
11968 }),
11969 };
11970 match r {
11971 Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
11972 Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
11973 }
11974 }
11975 }
11976
11977 fn mark_spawn_provisional(&self, address: SessionAddress) {
11978 let id = address.session.instance_id;
11979 self.provisional_spawns
11980 .lock()
11981 .unwrap_or_else(|poisoned| poisoned.into_inner())
11982 .insert(id, (address, Instant::now()));
11983 tracing::info!(
11984 node_id = %self.node_id,
11985 instance_id = id.0,
11986 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11987 "draft spawn; waiting for HQ view",
11988 );
11989 }
11990
11991 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11992 let removed = self
11993 .provisional_spawns
11994 .lock()
11995 .unwrap_or_else(|poisoned| poisoned.into_inner())
11996 .remove(&instance_id);
11997 if removed.is_some() {
11998 tracing::info!(
11999 node_id = %self.node_id,
12000 instance_id = instance_id.0,
12001 "draft spawn claimed by HQ view",
12002 );
12003 }
12004 }
12005
12006 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12007 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12008 let mut map = self
12009 .provisional_spawns
12010 .lock()
12011 .unwrap_or_else(|poisoned| poisoned.into_inner());
12012 let due: Vec<AgentInstanceId> = map
12013 .iter()
12014 .filter(|(_, (_, at))| at.elapsed() >= grace)
12015 .map(|(id, _)| *id)
12016 .collect();
12017 due.into_iter()
12018 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12019 .collect()
12020 }
12021
12022 async fn reap_provisional_spawn(&self, address: SessionAddress) {
12023 let instance_id = address.session.instance_id;
12024 tracing::warn!(
12025 node_id = %self.node_id,
12026 instance_id = instance_id.0,
12027 workspace = %address.workspace_id,
12028 reason = "attach_never_arrived",
12029 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12030 "draft spawn never viewed; stopping PTY",
12031 );
12032 let _ = self.dispatch(ControlCommand::Stop {
12033 instance_id,
12034 force: true,
12035 });
12036 let _ = self.remove_session(&address).await;
12037 }
12038
12039 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12040 if role != ClientRole::Operator {
12041 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12042 }
12043 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12044 let now = Instant::now();
12045 match controller.as_ref().copied() {
12046 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12047 Some(active) if active.expires_at <= now => {
12048 *controller = None;
12049 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12050 }
12051 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12052 }
12053 }
12054
12055 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12056 if !self
12057 .workspaces
12058 .read()
12059 .unwrap_or_else(|poisoned| poisoned.into_inner())
12060 .contains_key(&address.workspace_id)
12061 {
12062 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12063 }
12064 let binding = self
12065 .session_bindings
12066 .lock()
12067 .unwrap_or_else(|poisoned| poisoned.into_inner())
12068 .get(&address.session.instance_id)
12069 .cloned()
12070 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12071 if binding.workspace_id != address.workspace_id {
12072 return Err(failure(
12073 NodeFailureCode::SessionWorkspaceMismatch,
12074 "session belongs to another workspace",
12075 ));
12076 }
12077 if binding.generation != address.session.generation {
12078 return Err(failure(
12079 NodeFailureCode::StaleGeneration,
12080 &format!(
12081 "stale session generation: expected {}, received {}",
12082 binding.generation.0,
12083 address.session.generation.0,
12084 ),
12085 ));
12086 }
12087 let snapshot = self.handle.snapshot();
12088 let Some(current) = snapshot
12089 .sessions
12090 .iter()
12091 .find(|item| item.instance_id == address.session.instance_id)
12092 else {
12093 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12094 };
12095 if current.generation != address.session.generation {
12096 return Err(failure(
12097 NodeFailureCode::StaleGeneration,
12098 &format!(
12099 "stale session generation: expected {}, received {}",
12100 current.generation.0,
12101 address.session.generation.0,
12102 ),
12103 ));
12104 }
12105 Ok(current.agent_id.clone())
12106 }
12107
12108 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12109 self.dispatch_envelope(self.prepare_command(command))
12110 }
12111
12112 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12113 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12114 CommandEnvelope {
12115 id: CommandId(id),
12116 command,
12117 }
12118 }
12119
12120 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12121 let command_id = envelope.id;
12122 self.handle
12123 .dispatch(envelope)
12124 .map_err(|error| match error {
12125 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12126 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12127 })?;
12128 Ok(command_id)
12129 }
12130
12131 fn record_rejected_command(
12140 &self,
12141 command_id: CommandId,
12142 message: String,
12143 unsupported_transport: bool,
12144 ) {
12145 let now = Instant::now();
12146 let mut reasons = self.rejected_command_reasons.lock()
12147 .unwrap_or_else(|poisoned| poisoned.into_inner());
12148 reasons.retain(|_, reason| reason.expires_at > now);
12149 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12150 if let Some(&oldest) = reasons.keys().next() {
12151 reasons.remove(&oldest);
12152 }
12153 }
12154 reasons.insert(
12155 command_id,
12156 RejectedCommandReason {
12157 message,
12158 unsupported_transport,
12159 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12160 },
12161 );
12162 }
12163
12164 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12171 let now = Instant::now();
12172 let mut reasons = self.rejected_command_reasons.lock()
12173 .unwrap_or_else(|poisoned| poisoned.into_inner());
12174 match reasons.remove(&command_id) {
12175 Some(reason) if reason.expires_at > now => Some(reason),
12176 _ => None,
12177 }
12178 }
12179
12180 async fn dispatch_bounded(
12181 &self,
12182 command: ControlCommand,
12183 timeout_duration: Duration,
12184 ) -> Result<CommandId, NodeFailure> {
12185 let deadline = Instant::now() + timeout_duration;
12186 loop {
12187 match self.dispatch(command.clone()) {
12188 Ok(command_id) => return Ok(command_id),
12189 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12190 sleep(Duration::from_millis(2)).await;
12191 }
12192 Err(error) => return Err(error),
12193 }
12194 }
12195 }
12196
12197 fn internal_session_snapshot(
12198 &self,
12199 address: &SessionAddress,
12200 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12201 self.validate_address(address)?;
12202 self.handle
12203 .snapshot()
12204 .sessions
12205 .clone()
12206 .into_iter()
12207 .find(|session| {
12208 session.instance_id == address.session.instance_id
12209 && session.generation == address.session.generation
12210 })
12211 .ok_or_else(|| {
12212 failure(
12213 NodeFailureCode::UnknownSession,
12214 "session snapshot is unavailable",
12215 )
12216 })
12217 }
12218
12219 fn require_acp_session(
12227 &self,
12228 address: &SessionAddress,
12229 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12230 let snapshot = self.internal_session_snapshot(address)?;
12231 if snapshot.transport != TransportKind::Acp {
12232 return Err(failure(
12233 NodeFailureCode::UnsupportedTransport,
12234 "this verb requires a session spawned over the ACP transport",
12235 ));
12236 }
12237 Ok(snapshot)
12238 }
12239
12240 async fn dispatch_history_bounded(
12241 &self,
12242 address: &SessionAddress,
12243 command: ControlCommand,
12244 operation: HistoryOperation,
12245 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12246 self.validate_address(address)?;
12247 let started_after = self.current_sequence();
12248 let command_id = self
12249 .dispatch_bounded(
12250 command,
12251 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12252 )
12253 .await?;
12254 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12255 let mut scan_after = started_after;
12256 let mut accepted_after = None;
12257 loop {
12258 let events = self
12259 .history
12260 .lock()
12261 .unwrap_or_else(|poisoned| poisoned.into_inner())
12262 .events
12263 .iter()
12264 .filter(|envelope| envelope.sequence > scan_after)
12265 .cloned()
12266 .collect::<Vec<_>>();
12267 for envelope in events {
12268 scan_after = scan_after.max(envelope.sequence);
12269 let NodeEvent::Control {
12270 address: event_address,
12271 event,
12272 } = envelope.event
12273 else {
12274 continue;
12275 };
12276 if event_address != *address {
12277 continue;
12278 }
12279 if event.command_id == Some(command_id) {
12280 match &event.event {
12281 ControlEventKind::CommandRejected { .. } => {
12282 return Err(failure(
12283 NodeFailureCode::InvalidRequest,
12284 "history request was rejected",
12285 ));
12286 }
12287 ControlEventKind::HistoryRequested {
12288 operation: requested,
12289 ..
12290 } if requested == &operation => {
12291 accepted_after = Some(envelope.sequence);
12292 continue;
12293 }
12294 _ => {}
12295 }
12296 }
12297 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12298 match (&operation, &event.event) {
12299 (
12300 HistoryOperation::Discover { .. },
12301 ControlEventKind::HistoryDiscovered { .. },
12302 )
12303 | (
12304 HistoryOperation::Load { .. },
12305 ControlEventKind::HistoryLoaded { .. },
12306 ) => return self.internal_session_snapshot(address),
12307 (_, ControlEventKind::HistoryFailed { .. }) => {
12308 return Err(failure(
12309 NodeFailureCode::BackendOperationFailed,
12310 "history operation failed",
12311 ));
12312 }
12313 _ => {}
12314 }
12315 }
12316 }
12317 if Instant::now() >= deadline {
12318 return Err(failure(
12319 NodeFailureCode::BackendBusy,
12320 if accepted_after.is_some() {
12321 "history operation did not settle before the bounded deadline"
12322 } else {
12323 "history operation was not accepted before the bounded deadline"
12324 },
12325 ));
12326 }
12327 sleep(Duration::from_millis(2)).await;
12328 }
12329 }
12330
12331 async fn discover_history(
12332 &self,
12333 address: &SessionAddress,
12334 limit: u16,
12335 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12336 let query = HistoryQuery {
12337 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12338 limit,
12339 };
12340 query
12341 .validate()
12342 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12343 let snapshot = self
12344 .dispatch_history_bounded(
12345 address,
12346 ControlCommand::DiscoverHistory {
12347 instance_id: address.session.instance_id,
12348 query: query.clone(),
12349 },
12350 HistoryOperation::Discover { query },
12351 )
12352 .await?;
12353 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12354 return Err(failure(
12355 NodeFailureCode::BackendOperationFailed,
12356 "history discovery settled into an invalid state",
12357 ));
12358 }
12359 Ok(snapshot.history.candidates)
12360 }
12361
12362 async fn load_history(
12363 &self,
12364 address: &SessionAddress,
12365 candidate_id: String,
12366 ) -> Result<HistorySessionRecord, NodeFailure> {
12367 validate_candidate_id(&candidate_id)
12368 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12369 let snapshot = self
12370 .dispatch_history_bounded(
12371 address,
12372 ControlCommand::LoadHistory {
12373 instance_id: address.session.instance_id,
12374 candidate_id: candidate_id.clone(),
12375 },
12376 HistoryOperation::Load {
12377 candidate_id: candidate_id.clone(),
12378 },
12379 )
12380 .await?;
12381 if snapshot.history.pending.is_some()
12382 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12383 {
12384 return Err(failure(
12385 NodeFailureCode::BackendOperationFailed,
12386 "history load settled into an invalid state",
12387 ));
12388 }
12389 snapshot.history.loaded.ok_or_else(|| {
12390 failure(
12391 NodeFailureCode::BackendOperationFailed,
12392 "history load produced no bounded session",
12393 )
12394 })
12395 }
12396
12397 async fn export_context_pack_for_session_record(
12404 &self,
12405 record_id: &SessionRecordId,
12406 session: &SessionAddress,
12407 allow_clean_detachment: bool,
12408 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12409 let outcome = self
12415 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12416 .await;
12417 if let Err(failure) = &outcome {
12418 tracing::warn!(
12419 record = %record_id,
12420 code = ?failure.code,
12421 reason = %failure.message,
12422 "session-record context-pack export refused",
12423 );
12424 }
12425 outcome
12426 }
12427
12428 async fn export_context_pack_for_session_record_inner(
12429 &self,
12430 record_id: &SessionRecordId,
12431 session: &SessionAddress,
12432 allow_clean_detachment: bool,
12433 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12434 let step = |name: &'static str| move |failure: NodeFailure| {
12439 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12440 failure
12441 };
12442 let record = self.record(record_id).map_err(step("record-lookup"))?;
12443 let identity = match record.provider_session.clone() {
12459 Some(identity) => {
12460 if identity.key != ProviderSessionKey::SessionId {
12461 return Err(step("provider-identity-key")(failure(
12462 NodeFailureCode::BackendOperationFailed,
12463 "managed session provider identity cannot bind history exactly",
12464 )));
12465 }
12466 Some(identity)
12467 }
12468 None if record.state == ManagedSessionState::IdentityPending => {
12469 return Err(step("provider-session-identity")(failure(
12470 NodeFailureCode::BackendOperationFailed,
12471 "managed session has not yet observed its provider session identity",
12472 )));
12473 }
12474 None => None,
12475 };
12476 self.revalidate_session_record_context_export(
12477 &record,
12478 identity.as_ref(),
12479 session,
12480 allow_clean_detachment,
12481 ).map_err(step("revalidate-before-discovery"))?;
12482 let candidates = self
12483 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12484 .await
12485 .map_err(step("discover-history"))?;
12486 let candidate_id = select_history_candidate(
12491 &candidates,
12492 identity.as_ref().map(|identity| identity.id.as_str()),
12493 ).map_err(step("unique-candidate"))?;
12494 let loaded = self.load_history(session, candidate_id.clone()).await
12495 .map_err(step("load-history"))?;
12496 if let Some(identity) = identity.as_ref() {
12497 if loaded.session_id != identity.id {
12498 return Err(failure(
12499 NodeFailureCode::SessionRecordConflict,
12500 "loaded history identity does not match the managed session record",
12501 ));
12502 }
12503 }
12504 self.revalidate_session_record_context_export(
12505 &record,
12506 identity.as_ref(),
12507 session,
12508 allow_clean_detachment,
12509 )?;
12510 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12511 .map_err(step("revalidate-loaded-history"))?;
12512 let pack = self.materialize_context_pack(session).await
12513 .map_err(step("materialize-context-pack"))?;
12514 self.commit_context_pack_for_session_record(
12515 &record,
12516 identity.as_ref(),
12517 session,
12518 &candidate_id,
12519 &loaded,
12520 pack,
12521 allow_clean_detachment,
12522 )
12523 }
12524
12525 async fn export_context_pack(
12526 &self,
12527 address: &SessionAddress,
12528 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12529 let pack = self.materialize_context_pack(address).await?;
12530 self.commit_context_pack(pack)
12531 }
12532
12533 async fn materialize_context_pack(
12534 &self,
12535 address: &SessionAddress,
12536 ) -> Result<NodeContextPack, NodeFailure> {
12537 let snapshot = self.internal_session_snapshot(address)?;
12538 if !context_pack_source_status_is_usable(&snapshot.status) {
12539 return Err(failure(
12540 NodeFailureCode::InvalidRequest,
12541 "context export requires a running or successfully exited source session",
12542 ));
12543 }
12544 if snapshot.history.pending.is_some()
12545 || snapshot.history.loaded_candidate_id.is_none()
12546 {
12547 return Err(failure(
12548 NodeFailureCode::InvalidRequest,
12549 "context export requires settled loaded history",
12550 ));
12551 }
12552 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12553 failure(
12554 NodeFailureCode::InvalidRequest,
12555 "context export requires settled loaded history",
12556 )
12557 })?;
12558 let source_provider = snapshot.agent_id.clone();
12559 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12560 let repository = self
12561 .context_pack_repository(&address.workspace_id)
12562 .await?;
12563 let current = self.internal_session_snapshot(address)?;
12564 if !context_pack_source_status_is_usable(¤t.status)
12565 || current.agent_id != source_provider
12566 || current.history.pending.is_some()
12567 || current.history.loaded_candidate_id != loaded_candidate_id
12568 || current.history.loaded.as_ref() != Some(&loaded)
12569 {
12570 return Err(failure(
12571 NodeFailureCode::ContextPackMaterializationFailed,
12572 "context source changed during bounded repository capture",
12573 ));
12574 }
12575 NodeContextPack::export_with_repository(
12576 ContextPackLineageReceipt {
12577 source_node_id: self.node_id.clone(),
12578 source_session: address.clone(),
12579 source_provider,
12580 },
12581 &loaded,
12582 Some(repository),
12583 )
12584 .map_err(|_| {
12585 failure(
12586 NodeFailureCode::ContextPackMaterializationFailed,
12587 "bounded context pack export failed",
12588 )
12589 })
12590 }
12591
12592 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12598 let mut store = self
12599 .context_pack_store
12600 .lock()
12601 .unwrap_or_else(|poisoned| poisoned.into_inner());
12602 let Some(store) = store.as_mut() else {
12603 return Ok(());
12604 };
12605 store.commit(pack).map_err(|_| {
12606 failure(
12607 NodeFailureCode::ContextPackMaterializationFailed,
12608 "durable context pack commit failed",
12609 )
12610 })
12611 }
12612
12613 fn commit_context_pack(
12614 &self,
12615 pack: NodeContextPack,
12616 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12617 self.commit_context_pack_durably(&pack)?;
12618 self.context_catalog
12619 .write()
12620 .unwrap_or_else(|poisoned| poisoned.into_inner())
12621 .insert(pack)
12622 .map_err(|_| {
12623 failure(
12624 NodeFailureCode::ContextPackMaterializationFailed,
12625 "context pack catalog is full",
12626 )
12627 })
12628 }
12629
12630 fn commit_context_pack_for_session_record(
12631 &self,
12632 expected: &ManagedSessionRecord,
12633 identity: Option<&ProviderSessionIdentity>,
12634 session: &SessionAddress,
12635 candidate_id: &str,
12636 loaded: &HistorySessionRecord,
12637 pack: NodeContextPack,
12638 allow_clean_detachment: bool,
12639 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12640 let current_root = self.workspace_root(&expected.workspace_id)?;
12641 let _transaction = self
12642 .state_transaction
12643 .lock()
12644 .unwrap_or_else(|poisoned| poisoned.into_inner());
12645 let records = self
12646 .session_records
12647 .lock()
12648 .unwrap_or_else(|poisoned| poisoned.into_inner());
12649 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12650 failure(
12651 NodeFailureCode::SessionRecordConflict,
12652 "managed session record disappeared during context export",
12653 )
12654 })?;
12655 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12656 return Err(failure(
12657 NodeFailureCode::SessionRecordConflict,
12658 "managed session record changed during context export",
12659 ));
12660 }
12661 let bindings = self
12662 .session_bindings
12663 .lock()
12664 .unwrap_or_else(|poisoned| poisoned.into_inner());
12665 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12666 failure(
12667 NodeFailureCode::SessionRecordConflict,
12668 "managed session runtime binding disappeared during context export",
12669 )
12670 })?;
12671 let runtime = self
12672 .handle
12673 .snapshot()
12674 .sessions
12675 .iter()
12676 .find(|snapshot| {
12677 snapshot.instance_id == session.session.instance_id
12678 && snapshot.generation == session.session.generation
12679 })
12680 .cloned()
12681 .ok_or_else(|| {
12682 failure(
12683 NodeFailureCode::SessionRecordConflict,
12684 "managed session runtime disappeared during context export",
12685 )
12686 })?;
12687 let receipt = pack.receipt();
12688 if !session_record_context_export_binding_is_exact(
12689 expected,
12690 current,
12691 identity,
12692 session,
12693 binding,
12694 &runtime.agent_id,
12695 ¤t_root,
12696 allow_clean_detachment,
12697 ) || !session_record_context_export_source_is_usable(
12698 current,
12699 &runtime.status,
12700 allow_clean_detachment,
12701 ) || runtime.history.pending.is_some()
12702 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12703 || runtime.history.loaded.as_ref() != Some(loaded)
12704 || receipt.lineage.source_node_id != self.node_id
12705 || receipt.lineage.source_session != *session
12706 || receipt.lineage.source_provider != current.provider
12707 || receipt.source_message_count != loaded.message_count
12708 {
12709 return Err(failure(
12710 NodeFailureCode::SessionRecordConflict,
12711 "managed session source changed before context catalog commit",
12712 ));
12713 }
12714 self.commit_context_pack_durably(&pack)?;
12715 self.context_catalog
12716 .write()
12717 .unwrap_or_else(|poisoned| poisoned.into_inner())
12718 .insert(pack)
12719 .map_err(|_| {
12720 failure(
12721 NodeFailureCode::ContextPackMaterializationFailed,
12722 "context pack catalog is full",
12723 )
12724 })
12725 }
12726
12727 async fn context_pack_repository(
12728 &self,
12729 workspace_id: &WorkspaceId,
12730 ) -> Result<ContextPackRepository, NodeFailure> {
12731 let first = self
12732 .context_pack_repository_observation(workspace_id)
12733 .await?;
12734 let second = self
12735 .context_pack_repository_observation(workspace_id)
12736 .await?;
12737 stable_context_pack_repository(first, second)
12738 }
12739
12740 async fn context_pack_repository_observation(
12741 &self,
12742 workspace_id: &WorkspaceId,
12743 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12744 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12745 let head = if git.is_repository {
12746 let root = self.workspace_root(workspace_id)?;
12747 match resolve_base_commit_with_timeout(
12748 &root,
12749 "HEAD",
12750 GIT_COMMAND_TIMEOUT_MS,
12751 )
12752 .await
12753 {
12754 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12755 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12756 ContextPackRepositoryHead::Unborn
12757 }
12758 Err(_) => {
12759 return Err(failure(
12760 NodeFailureCode::ContextPackMaterializationFailed,
12761 "context pack HEAD observation failed",
12762 ));
12763 }
12764 }
12765 } else {
12766 ContextPackRepositoryHead::NotRepository
12767 };
12768 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12769 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12770 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12771 failure(
12772 NodeFailureCode::InvalidRepositoryPath,
12773 "context pack selected file path is invalid",
12774 )
12775 })?;
12776 match self.read_workspace_file(workspace_id.clone(), path).await {
12777 Ok(WorkspaceFileRead {
12778 content: WorkspaceFileContent::Utf8 { text, byte_len },
12779 ..
12780 }) => files.push(ContextPackRepositoryFileSource::utf8(
12781 selected_path,
12782 text,
12783 byte_len,
12784 )),
12785 Ok(WorkspaceFileRead {
12786 content: WorkspaceFileContent::NonUtf8 { byte_len },
12787 ..
12788 }) => files.push(ContextPackRepositoryFileSource::skipped(
12789 selected_path,
12790 ContextPackSelectedFileSkipReason::NonUtf8,
12791 Some(byte_len),
12792 )),
12793 Ok(WorkspaceFileRead {
12794 content: WorkspaceFileContent::TooLarge { .. },
12795 ..
12796 }) => files.push(ContextPackRepositoryFileSource::skipped(
12797 selected_path,
12798 ContextPackSelectedFileSkipReason::TooLarge,
12799 None,
12800 )),
12801 Err(error) => {
12802 let reason = if matches!(
12803 error.code,
12804 NodeFailureCode::InvalidRepositoryPath
12805 | NodeFailureCode::RepositoryFileNotRegular
12806 | NodeFailureCode::RepositoryPathUnsafe
12807 ) {
12808 ContextPackSelectedFileSkipReason::Unsafe
12809 } else {
12810 ContextPackSelectedFileSkipReason::Unavailable
12811 };
12812 files.push(ContextPackRepositoryFileSource::skipped(
12813 selected_path,
12814 reason,
12815 None,
12816 ));
12817 }
12818 }
12819 }
12820 Ok(ContextPackRepositoryObservation { head, git, files })
12821 }
12822
12823 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12824 let referenced_by_binding = self
12825 .session_bindings
12826 .lock()
12827 .unwrap_or_else(|poisoned| poisoned.into_inner())
12828 .values()
12829 .any(|binding| {
12830 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12831 });
12832 let referenced_by_record = self
12833 .session_records
12834 .lock()
12835 .unwrap_or_else(|poisoned| poisoned.into_inner())
12836 .records
12837 .values()
12838 .any(|record| record.context_id.as_ref() == Some(context_id));
12839 let referenced_by_materialization = self
12840 .materializations
12841 .lock()
12842 .unwrap_or_else(|poisoned| poisoned.into_inner())
12843 .values()
12844 .any(|ownership| {
12845 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12846 });
12847 if referenced_by_binding
12848 || referenced_by_record
12849 || referenced_by_materialization
12850 {
12851 return Err(failure(
12852 NodeFailureCode::ContextPackBusy,
12853 "context pack is still referenced",
12854 ));
12855 }
12856 let mut catalog = self.context_catalog
12857 .write()
12858 .unwrap_or_else(|poisoned| poisoned.into_inner());
12859 if catalog.get(context_id).is_none() {
12860 return Err(failure(
12861 NodeFailureCode::UnknownContextPack,
12862 "context pack is unavailable",
12863 ));
12864 }
12865 self.spawn_idempotency
12866 .lock()
12867 .unwrap_or_else(|poisoned| poisoned.into_inner())
12868 .entries
12869 .retain(|_, entry| !entry.value.references_context(context_id));
12870 let removed = catalog.remove(context_id);
12871 debug_assert!(removed.is_some(), "validated context pack remains present");
12872 Ok(())
12873 }
12874
12875 async fn dispatch_input_bounded(
12876 &self,
12877 address: &SessionAddress,
12878 action: InputAction,
12879 ) -> Result<(), NodeFailure> {
12880 let started_after = self.current_sequence();
12881 self.pty_input_marks
12882 .lock()
12883 .unwrap_or_else(|poisoned| poisoned.into_inner())
12884 .insert(address.session.instance_id, (Instant::now(), false));
12885 self.pty_input_wake.notify_one();
12886 let command_id = self
12887 .dispatch_bounded(
12888 ControlCommand::SendInput {
12889 instance_id: address.session.instance_id,
12890 action,
12891 },
12892 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12893 )
12894 .await?;
12895 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12899 let mut scan_after = started_after;
12900 let mut accepted_after = None;
12901 loop {
12902 let events = self
12903 .history
12904 .lock()
12905 .unwrap_or_else(|poisoned| poisoned.into_inner())
12906 .events
12907 .iter()
12908 .filter(|envelope| envelope.sequence > scan_after)
12909 .cloned()
12910 .collect::<Vec<_>>();
12911 for envelope in events {
12912 scan_after = scan_after.max(envelope.sequence);
12913 let NodeEvent::Control {
12914 address: event_address,
12915 event,
12916 } = envelope.event
12917 else {
12918 continue;
12919 };
12920 if event_address != *address {
12921 continue;
12922 }
12923 if event.command_id == Some(command_id) {
12924 match &event.event {
12925 ControlEventKind::CommandRejected { message } => {
12926 return Err(failure(NodeFailureCode::InvalidRequest, message));
12927 }
12928 ControlEventKind::InputRequested { .. } => {
12929 accepted_after = Some(envelope.sequence);
12930 continue;
12931 }
12932 _ => {}
12933 }
12934 }
12935 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12936 match &event.event {
12937 ControlEventKind::InputCompleted { .. } => return Ok(()),
12938 ControlEventKind::InputFailed { message, .. } => {
12939 return Err(failure(
12940 NodeFailureCode::BackendOperationFailed,
12941 message,
12942 ));
12943 }
12944 _ => {}
12945 }
12946 }
12947 }
12948 if Instant::now() >= deadline {
12949 return Err(failure(
12950 NodeFailureCode::BackendBusy,
12951 if accepted_after.is_some() {
12952 "input was accepted but did not settle before the bounded deadline"
12953 } else {
12954 "input was not accepted before the bounded deadline"
12955 },
12956 ));
12957 }
12958 sleep(Duration::from_millis(2)).await;
12959 }
12960 }
12961
12962 async fn dispatch_resize_bounded(
12963 &self,
12964 address: &SessionAddress,
12965 size: gate4agent_types::TerminalSize,
12966 ) -> Result<(), NodeFailure> {
12967 let started_after = self.current_sequence();
12968 let command_id = self
12969 .dispatch_bounded(
12970 ControlCommand::Resize {
12971 instance_id: address.session.instance_id,
12972 size,
12973 },
12974 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12975 )
12976 .await?;
12977 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12978 let mut scan_after = started_after;
12979 let mut accepted_after = None;
12980 loop {
12981 let events = self
12982 .history
12983 .lock()
12984 .unwrap_or_else(|poisoned| poisoned.into_inner())
12985 .events
12986 .iter()
12987 .filter(|envelope| envelope.sequence > scan_after)
12988 .cloned()
12989 .collect::<Vec<_>>();
12990 for envelope in events {
12991 scan_after = scan_after.max(envelope.sequence);
12992 let NodeEvent::Control {
12993 address: event_address,
12994 event,
12995 } = envelope.event
12996 else {
12997 continue;
12998 };
12999 if event_address != *address {
13000 continue;
13001 }
13002 if event.command_id == Some(command_id) {
13003 match &event.event {
13004 ControlEventKind::CommandRejected { message } => {
13005 return Err(failure(NodeFailureCode::InvalidRequest, message));
13006 }
13007 ControlEventKind::ResizeRequested {
13008 size: requested_size,
13009 ..
13010 } if *requested_size == size => {
13011 accepted_after = Some(envelope.sequence);
13012 continue;
13013 }
13014 _ => {}
13015 }
13016 }
13017 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13018 match &event.event {
13019 ControlEventKind::Resized { size: settled_size }
13020 if *settled_size == size =>
13021 {
13022 return Ok(());
13023 }
13024 ControlEventKind::ResizeFailed { message } => {
13025 return Err(failure(
13026 NodeFailureCode::BackendOperationFailed,
13027 message,
13028 ));
13029 }
13030 _ => {}
13031 }
13032 }
13033 }
13034 if Instant::now() >= deadline {
13035 return Err(failure(
13036 NodeFailureCode::BackendBusy,
13037 if accepted_after.is_some() {
13038 "resize was accepted but did not settle before the bounded deadline"
13039 } else {
13040 "resize was not accepted before the bounded deadline"
13041 },
13042 ));
13043 }
13044 sleep(Duration::from_millis(2)).await;
13045 }
13046 }
13047
13048 async fn spawn_session(
13049 &self,
13050 workspace_id: WorkspaceId,
13051 provider: AgentId,
13052 mode: SessionMode,
13053 terminal_size: gate4agent_types::TerminalSize,
13054 initial_prompt: Option<String>,
13055 ) -> Result<SessionAddress, NodeFailure> {
13056 if self.managed_worktrees.lock()
13057 .unwrap_or_else(|poisoned| poisoned.into_inner())
13058 .lease_for_workspace(&workspace_id)
13059 .is_some()
13060 {
13061 return Err(failure(
13062 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13063 "managed worktree targets may only be spawned through their lease request",
13064 ));
13065 }
13066 self
13067 .spawn_session_with_deadline(
13068 workspace_id,
13069 provider.clone(),
13070 mode,
13071 terminal_size,
13072 initial_prompt,
13073 None,
13074 None,
13075 None,
13076 None,
13077 None,
13078 SpawnRecordPolicy::ProviderIdentityOnly,
13079 None,
13080 &[],
13081 None,
13082 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13089 None,
13090 None,
13091 )
13092 .await
13093 .map(|(session, _runtime_policy)| session)
13094 }
13095
13096 async fn spawn_session_with_deadline(
13097 &self,
13098 workspace_id: WorkspaceId,
13099 provider: AgentId,
13100 mode: SessionMode,
13101 terminal_size: gate4agent_types::TerminalSize,
13102 initial_prompt: Option<String>,
13103 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13104 bundle: Option<ResolvedBundleReceipt>,
13105 context: Option<ResolvedContextPackReceipt>,
13106 managed_authority: Option<ManagedWorktreeLeaseId>,
13107 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13108 record_policy: SpawnRecordPolicy,
13109 deadline: Option<Instant>,
13110 required_capabilities: &[ProviderRuntimeCapability],
13111 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13112 approval_level: ApprovalLevel,
13113 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13114 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13120 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13121 let reserved_lease = self.managed_worktrees.lock()
13122 .unwrap_or_else(|poisoned| poisoned.into_inner())
13123 .lease_for_workspace(&workspace_id);
13124 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13125 (Some(expected), Some(provided)) if expected == provided => {}
13126 (None, None) => {}
13127 _ => {
13128 return Err(failure(
13129 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13130 "spawn lacks exact managed worktree lease authority",
13131 ));
13132 }
13133 }
13134 self.ensure_workspace_environment_profile_compatible(
13138 &workspace_id,
13139 environment_profile.as_ref(),
13140 )?;
13141 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13142 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13143 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13144 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13145 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13146 };
13147 let transport = match mode {
13153 SessionMode::Pty => TransportKind::Pty,
13154 SessionMode::Inline => TransportKind::Pipe,
13155 SessionMode::Acp => TransportKind::Acp,
13156 };
13157 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13158 let acp_transport = self.provider_supports_acp_transport(&provider);
13159 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13160 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13161 NodeFailureCode::UnsupportedSpawnCapability,
13162 "pre-admitted provider runtime no longer satisfies spawn requirements",
13163 ))?;
13164 runtime_policy
13165 } else if let Some(deadline) = deadline {
13166 let remaining = spawn_deadline_remaining(deadline)?;
13167 timeout(
13168 remaining,
13169 self.admit_provider_runtime(&provider, runtime_requirement),
13170 )
13171 .await
13172 .map_err(|_| {
13173 failure(
13174 NodeFailureCode::SpawnDeadlineExceeded,
13175 "provider admission exceeded the spawn deadline",
13176 )
13177 })??
13178 } else {
13179 self.admit_provider_runtime(&provider, runtime_requirement).await?
13180 };
13181 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13182 let runtime_policy =
13194 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13195 if required_capabilities
13196 .iter()
13197 .any(|capability| !runtime_policy.admits(*capability))
13198 {
13199 return Err(failure(
13200 NodeFailureCode::UnsupportedSpawnCapability,
13201 "provider runtime does not admit a required spawn capability",
13202 ));
13203 }
13204 if let Some(deadline) = deadline {
13205 spawn_deadline_remaining(deadline)?;
13206 }
13207 self.ensure_binding_capacity()?;
13208 let mut working_directory = self.workspace_root(&workspace_id)?;
13209 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13210 let session = SessionKey {
13211 instance_id,
13212 generation: SessionGeneration(1),
13213 };
13214 let address = SessionAddress {
13215 workspace_id: workspace_id.clone(),
13216 session,
13217 };
13218 tracing::info!(
13226 node_id = %self.node_id,
13227 workspace_id = %workspace_id,
13228 provider = %provider,
13229 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13230 session = ?address.session,
13231 "session spawn request accepted",
13232 );
13233 let mut prepared_spawn = SpawnPreparer::prepare(
13234 self,
13235 SpawnPrepareRequest {
13236 instance_id,
13237 address: &address,
13238 provider: &provider,
13239 mode,
13240 environment_profile: environment_profile.as_ref(),
13241 bundle: bundle.as_ref(),
13242 context: context.as_ref(),
13243 managed_authority: managed_authority.as_ref(),
13244 harness_mcp,
13245 approval_level,
13246 network_allowlist: network_allowlist.as_ref(),
13247 browser_profile_id,
13248 },
13249 )?;
13250 let materialization_id = prepared_spawn.materialization_id.take();
13251 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13252 let record_id = self.bind_spawn_session_with_materialization(
13253 &address,
13254 provider.clone(),
13255 mode,
13256 runtime_policy,
13257 record_policy,
13258 environment_profile,
13259 bundle,
13260 context,
13261 materialization_id,
13262 )?;
13263 prepared_spawn.retain_session_guards();
13264 let session_provider = provider.clone();
13268 let agent_id = provider;
13269 let register_command_id = match self.dispatch_bounded(
13270 ControlCommand::Register {
13271 instance_id,
13272 agent_id,
13273 transport,
13274 },
13275 dispatch_timeout,
13276 )
13277 .await {
13278 Ok(command_id) => command_id,
13279 Err(error) => {
13280 if let Some(binding) = self.remove_binding(&address) {
13281 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13282 }
13283 if let Some(record_id) = record_id.as_ref() {
13284 self.discard_record(record_id)?;
13285 }
13286 return Err(spawn_dispatch_error(error, deadline));
13287 }
13288 };
13289 let start_timeout = match spawn_dispatch_timeout(deadline) {
13290 Ok(timeout) => timeout,
13291 Err(error) => {
13292 let recovery = self
13293 .rollback_spawn(
13294 &address,
13295 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13296 )
13297 .await;
13298 return match recovery {
13299 Ok(()) => {
13300 if let Some(record_id) = record_id.as_ref() {
13301 self.discard_record(record_id)?;
13302 }
13303 Err(error)
13304 }
13305 Err(recovery_error) => {
13306 if let Some(record_id) = record_id.as_ref() {
13307 self.mark_record_error(
13308 record_id,
13309 "spawn deadline elapsed and registration recovery failed",
13310 )?;
13311 }
13312 Err(recovery_error)
13313 }
13314 };
13315 }
13316 };
13317 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13318 if let Err(recovery_error) = self
13319 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13320 .await
13321 {
13322 return Err(recovery_error);
13323 }
13324 if let Some(record_id) = record_id.as_ref() {
13325 self.discard_record(record_id)?;
13326 }
13327 return Err(start_error);
13328 }
13329 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13331 working_directory = dir.to_string_lossy().into_owned();
13332 }
13333 let start_result = self
13334 .dispatch_bounded(
13335 ControlCommand::Start {
13336 instance_id,
13337 runtime_policy,
13338 request: StartRequest {
13339 working_directory,
13340 terminal_size,
13341 initial_prompt,
13342 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13343 approval_level,
13344 },
13345 },
13346 start_timeout,
13347 )
13348 .await;
13349 let start_command_id = match start_result {
13350 Ok(command_id) => command_id,
13351 Err(start_error) => {
13352 let start_error = spawn_dispatch_error(start_error, deadline);
13353 let recovery = self
13354 .rollback_spawn(
13355 &address,
13356 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13357 )
13358 .await;
13359 return match recovery {
13360 Ok(()) => {
13361 if let Some(record_id) = record_id.as_ref() {
13362 self.discard_record(record_id)?;
13363 }
13364 Err(start_error)
13365 }
13366 Err(recovery_error) => {
13367 let diagnostic = format!(
13368 "start failed and registration recovery failed: {}",
13369 recovery_error.message,
13370 );
13371 if let Some(record_id) = record_id.as_ref() {
13372 self.mark_record_error(record_id, &diagnostic)?;
13373 }
13374 Err(failure(recovery_error.code, &diagnostic))
13375 }
13376 };
13377 }
13378 };
13379
13380 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13381 loop {
13382 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13391 .or_else(|| self.take_rejected_command_reason(start_command_id))
13392 {
13393 self.rollback_spawn(
13394 &address,
13395 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13396 ).await.map_err(|error| {
13397 failure(
13398 error.code,
13399 &format!(
13400 "spawn was rejected ({}) and registration recovery failed: {}",
13401 reason.message, error.message
13402 ),
13403 )
13404 })?;
13405 if let Some(record_id) = record_id.as_ref() {
13406 self.discard_record(record_id)?;
13407 }
13408 let code = if reason.unsupported_transport {
13409 NodeFailureCode::UnsupportedTransport
13410 } else {
13411 NodeFailureCode::BackendOperationFailed
13412 };
13413 return Err(failure(code, &reason.message));
13414 }
13415 if let Some(current) = self
13416 .handle
13417 .snapshot()
13418 .sessions
13419 .iter()
13420 .find(|current| current.instance_id == instance_id)
13421 {
13422 if current.generation != session.generation {
13423 let divergence = failure(
13424 NodeFailureCode::StaleGeneration,
13425 "spawn generation diverged",
13426 );
13427 return match self
13428 .rollback_spawn(
13429 &address,
13430 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13431 )
13432 .await
13433 {
13434 Ok(()) => {
13435 if let Some(record_id) = record_id.as_ref() {
13436 self.discard_record(record_id)?;
13437 }
13438 Err(divergence)
13439 }
13440 Err(recovery_error) => {
13441 if let Some(record_id) = record_id.as_ref() {
13442 self.mark_record_error(
13443 record_id,
13444 "spawn generation diverged and recovery failed",
13445 )?;
13446 }
13447 Err(recovery_error)
13448 }
13449 };
13450 }
13451 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13452 prepared_spawn.retain_launch_guards();
13453 return Ok((address, runtime_policy));
13454 }
13455 }
13456 if Instant::now() >= commit_deadline {
13457 self.rollback_spawn(
13458 &address,
13459 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13460 ).await.map_err(|error| {
13461 failure(
13462 error.code,
13463 &format!(
13464 "start did not commit and registration recovery failed: {}",
13465 error.message
13466 ),
13467 )
13468 })?;
13469 if let Some(record_id) = record_id.as_ref() {
13470 self.discard_record(record_id)?;
13471 }
13472 return Err(failure(
13473 if deadline.is_some() {
13474 NodeFailureCode::SpawnDeadlineExceeded
13475 } else {
13476 NodeFailureCode::BackendBusy
13477 },
13478 "start did not commit before the bounded deadline; registration was removed",
13479 ));
13480 }
13481 sleep(Duration::from_millis(2)).await;
13482 }
13483 }
13484
13485 fn effective_spawn_runtime_policy(
13486 &self,
13487 runtime_policy: ProviderRuntimePolicy,
13488 ) -> ProviderRuntimePolicy {
13489 #[cfg(feature = "fixture")]
13490 let runtime_policy = if self.fixture_semantic_hook_policy {
13491 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13492 .expect("monitoring Hook fixture policy is internally valid")
13493 } else {
13494 runtime_policy
13495 };
13496 runtime_policy
13497 }
13498
13499 fn verify_harness_mcp_before_start(
13500 &self,
13501 prepared: Option<&PreparedHarnessMcpSpawn>,
13502 ) -> Result<(), NodeFailure> {
13503 prepared
13504 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13505 .transpose()
13506 .map(|_| ())
13507 .map_err(harness_mcp_failure)
13508 }
13509
13510 async fn rollback_spawn(
13511 &self,
13512 address: &SessionAddress,
13513 commit_timeout: Duration,
13514 ) -> Result<(), NodeFailure> {
13515 self.dispatch_bounded(
13516 ControlCommand::Remove {
13517 instance_id: address.session.instance_id,
13518 },
13519 commit_timeout,
13520 )
13521 .await?;
13522 self.wait_until_removed(address, commit_timeout).await?;
13523 if let Some(binding) = self.remove_binding(address) {
13524 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13525 }
13526 Ok(())
13527 }
13528
13529 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13530 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13531 self.dispatch_bounded(
13532 ControlCommand::Remove {
13533 instance_id: address.session.instance_id,
13534 },
13535 commit_timeout,
13536 )
13537 .await?;
13538 self.wait_until_removed(address, commit_timeout).await?;
13539 let removed = self.remove_binding(address);
13540 if let Some(binding) = removed.as_ref() {
13541 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13542 }
13543 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13544 let snapshot = self.managed_worktrees.lock()
13545 .unwrap_or_else(|poisoned| poisoned.into_inner())
13546 .release_session(
13547 &lease_id,
13548 self.incarnation_id,
13549 address.session.instance_id,
13550 address.session.generation,
13551 unix_time_ms(),
13552 );
13553 if self.persist_state().is_err() {
13554 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13555 }
13556 if let Some(snapshot) = snapshot {
13557 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13558 }
13559 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13560 }
13561 Ok(())
13562 }
13563
13564 async fn wait_until_removed(
13610 &self,
13611 address: &SessionAddress,
13612 commit_timeout: Duration,
13613 ) -> Result<(), NodeFailure> {
13614 let deadline = Instant::now() + commit_timeout;
13615 let mut next_dispatch = Instant::now();
13616 loop {
13617 let snapshot = self.handle.snapshot();
13618 let status = snapshot
13619 .sessions
13620 .iter()
13621 .find(|session| session.instance_id == address.session.instance_id)
13622 .map(|session| session.status.clone());
13623 let Some(status) = status else {
13624 return Ok(());
13625 };
13626 let now = Instant::now();
13627 if now >= deadline {
13628 return Err(failure(
13629 NodeFailureCode::BackendBusy,
13630 "remove did not commit before the bounded deadline; session binding was retained",
13631 ));
13632 }
13633 if status.allows_remove() && now >= next_dispatch {
13634 let _ = self.dispatch(ControlCommand::Remove {
13635 instance_id: address.session.instance_id,
13636 });
13637 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13638 }
13639 sleep(Duration::from_millis(2)).await;
13640 }
13641 }
13642
13643 fn resync(&self, after_sequence: u64) -> NodeResponse {
13644 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13645 let event_sequence = history.last_sequence;
13646 let oldest_available_sequence = history.replay_floor_sequence;
13647 let events = history
13648 .events
13649 .iter()
13650 .filter(|event| event.sequence > after_sequence)
13651 .cloned()
13652 .collect();
13653 drop(history);
13654 NodeResponse::Resync {
13655 event_sequence,
13656 oldest_available_sequence,
13657 snapshot: self.snapshot(),
13658 events,
13659 }
13660 }
13661}
13662
13663fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13668 match kind {
13669 AgentStreamChunkKindV1::Text { .. } => "text",
13670 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13671 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13672 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13673 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13674 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13675 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13676 }
13677}
13678
13679async fn accept_connections(
13693 endpoint: &str,
13694 call_home: Option<std::net::SocketAddr>,
13695 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13696 shared: Arc<NodeShared>,
13697) -> Result<(), NodeServerError> {
13698 let mut connections = JoinSet::new();
13699 let result = match call_home {
13700 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13701 Some(relay) => {
13702 let listener =
13703 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13704 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13705 tokio::pin!(listener);
13706 tokio::pin!(dialler);
13707 tokio::select! {
13708 result = &mut listener => result,
13709 result = &mut dialler => result,
13710 }
13711 }
13712 };
13713 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13714 while !connections.is_empty() {
13715 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13716 if remaining.is_zero()
13717 || timeout(remaining, connections.join_next()).await.is_err()
13718 {
13719 break;
13720 }
13721 }
13722 connections.abort_all();
13723 while connections.join_next().await.is_some() {}
13724 result
13725}
13726
13727const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13731
13732async fn call_home_loop(
13746 relay: std::net::SocketAddr,
13747 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13748 shared: Arc<NodeShared>,
13749) -> Result<(), NodeServerError> {
13750 let mut failures = 0_usize;
13751 loop {
13752 if shared.shutdown.load(Ordering::Acquire) {
13753 return Ok(());
13754 }
13755 let permit = tokio::select! {
13756 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13757 permit.map_err(|_| NodeServerError::PreauthClosed)?
13758 }
13759 _ = shared.shutdown_notify.notified() => return Ok(()),
13760 };
13761 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13762 Ok(()) => {
13763 failures = 0;
13767 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13768 }
13769 Err(error) => {
13770 failures = failures.saturating_add(1);
13771 tracing::warn!(
13772 node_id = %shared.node_id,
13773 %relay,
13774 cause = %error,
13775 attempt = failures,
13776 "call-home connection failed",
13777 );
13778 let backoff = call_home_backoff(failures);
13779 tokio::select! {
13780 _ = sleep(backoff) => {}
13781 _ = shared.shutdown_notify.notified() => return Ok(()),
13782 }
13783 }
13784 }
13785 }
13786}
13787
13788const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13790const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13792
13793fn call_home_keepalive() -> socket2::TcpKeepalive {
13794 let keepalive = socket2::TcpKeepalive::new()
13795 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13796 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13797 #[cfg(not(windows))]
13798 let keepalive = keepalive.with_retries(3);
13799 keepalive
13800}
13801
13802fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13803 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13804}
13805
13806pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13808 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13809}
13810
13811fn call_home_backoff(attempt: usize) -> Duration {
13813 Duration::from_millis(
13814 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13815 )
13816}
13817
13818
13819async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13822 #[cfg(feature = "wireguard")]
13823 {
13824 let std_stream = tokio::task::spawn_blocking(move || {
13825 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13826 })
13827 .await
13828 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13829 .map_err(NodeServerError::CallHomeTransport)?;
13830 std_stream
13831 .set_nonblocking(true)
13832 .map_err(NodeServerError::CallHomeTransport)?;
13833 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13834 }
13835 #[cfg(not(feature = "wireguard"))]
13836 {
13837 tokio::net::TcpStream::connect(relay)
13838 .await
13839 .map_err(NodeServerError::CallHomeTransport)
13840 }
13841}
13842
13843async fn dial_relay_and_serve(
13845 relay: std::net::SocketAddr,
13846 identity: Option<&hatchery_enroll::NodeIdentity>,
13847 shared: Arc<NodeShared>,
13848 preauth_permit: OwnedSemaphorePermit,
13849) -> Result<(), NodeServerError> {
13850 let mut stream = dial_call_home(relay).await?;
13851 let _ = stream.set_nodelay(true);
13855 if let Err(error) = enable_call_home_keepalive(&stream) {
13861 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13862 }
13863 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13864 .await
13865 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13866 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13867 let mut session_token = None;
13868 if let Some(identity) = identity {
13869 let mut magic = [0_u8; 4];
13873 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13874 .await
13875 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13876 .map_err(NodeServerError::CallHomeTransport)?;
13877 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13878 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13879 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13880 .await
13881 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13882 .map_err(NodeServerError::CallHomeTransport)?;
13883 let (answer, token) = identity
13884 .answer_challenge(shared.node_id.as_str(), &frame)
13885 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13886 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13887 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13888 session_token = Some(token);
13889 }
13890 }
13891 serve_connection(stream, shared, session_token, preauth_permit).await
13892}
13893
13894async fn accept_connections_inner(
13895 endpoint: &str,
13896 shared: Arc<NodeShared>,
13897 connections: &mut JoinSet<Result<(), NodeServerError>>,
13898) -> Result<(), NodeServerError> {
13899 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13900 loop {
13901 while connections.try_join_next().is_some() {}
13902 if shared.shutdown.load(Ordering::Acquire) {
13903 return Ok(());
13904 }
13905 let preauth_permit = tokio::select! {
13906 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13907 permit.map_err(|_| NodeServerError::PreauthClosed)?
13908 }
13909 _ = shared.shutdown_notify.notified() => return Ok(()),
13910 };
13911 let server = tokio::select! {
13912 result = listener.accept() => result?,
13913 _ = shared.shutdown_notify.notified() => return Ok(()),
13914 };
13915 let connection_shared = Arc::clone(&shared);
13916 connections.spawn(async move {
13917 serve_connection(server, connection_shared, None, preauth_permit).await
13918 });
13919 if shared.shutdown.load(Ordering::Acquire) {
13920 return Ok(());
13921 }
13922 }
13923}
13924
13925async fn serve_connection<S>(
13933 mut pipe: S,
13934 shared: Arc<NodeShared>,
13935 session_token: Option<String>,
13936 preauth_permit: OwnedSemaphorePermit,
13937) -> Result<(), NodeServerError>
13938where
13939 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13940{
13941 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13943 let hello_frame = timeout(
13944 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13945 read_json_frame_limited_body_timeout(
13946 &mut pipe,
13947 MAX_NODE_HELLO_FRAME_BYTES,
13948 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13949 ),
13950 )
13951 .await
13952 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13953 let ClientFrame::Hello(hello) = hello_frame else {
13954 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13955 };
13956 if hello.build_stamp != BUILD_STAMP {
13957 return Err(NodeServerError::Handshake(format!(
13958 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13959 hello.build_stamp,
13960 )));
13961 }
13962 let compatibility = match hello.compatibility.as_ref() {
13963 Some(offer) => {
13964 let mut selected = node_compatibility_support(&shared)?
13965 .negotiate(offer)
13966 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13967 project_negotiated_provider_ids(&mut selected);
13968 Some(selected)
13969 }
13970 None => None,
13971 };
13972 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13973 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13974 (Some(offer), Some(selected)) => negotiated_auth_proof(
13975 auth_token,
13976 AuthDirection::Server,
13977 hello.role,
13978 &hello.client_nonce,
13979 &server_nonce,
13980 offer,
13981 selected,
13982 ),
13983 (None, None) => auth_proof(
13984 auth_token,
13985 AuthDirection::Server,
13986 hello.role,
13987 &hello.client_nonce,
13988 &server_nonce,
13989 ),
13990 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13991 }
13992 .map_err(NodeServerError::Authentication)?;
13993 write_json_frame_limited(
13994 &mut pipe,
13995 &ServerFrame::Challenge(ServerChallenge {
13996 build_stamp: BUILD_STAMP.to_owned(),
13997 server_nonce,
13998 server_proof,
13999 compatibility: compatibility.clone(),
14000 }),
14001 MAX_NODE_HELLO_FRAME_BYTES,
14002 )
14003 .await?;
14004 let authentication = timeout(
14005 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14006 read_json_frame_limited_body_timeout(
14007 &mut pipe,
14008 MAX_NODE_HELLO_FRAME_BYTES,
14009 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14010 ),
14011 )
14012 .await
14013 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14014 let ClientFrame::Authenticate(authentication) = authentication else {
14015 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14016 };
14017 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14018 (Some(offer), Some(selected)) => negotiated_auth_proof(
14019 auth_token,
14020 AuthDirection::Client,
14021 hello.role,
14022 &hello.client_nonce,
14023 &server_nonce,
14024 offer,
14025 selected,
14026 ),
14027 (None, None) => auth_proof(
14028 auth_token,
14029 AuthDirection::Client,
14030 hello.role,
14031 &hello.client_nonce,
14032 &server_nonce,
14033 ),
14034 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14035 }
14036 .map_err(NodeServerError::Authentication)?;
14037 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14038 return Err(NodeServerError::Handshake("access denied".to_owned()));
14039 }
14040 let selected_capabilities = compatibility
14041 .as_ref()
14042 .map(|selected| selected.capabilities.clone())
14043 .unwrap_or_default();
14044 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14045 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14046 });
14047 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14048 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14049 });
14050 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14051 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14052 });
14053 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14054 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14055 });
14056 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14057 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14058 });
14059 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14060 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14061 }) && selected_capabilities.iter().any(|capability| {
14062 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14063 });
14064 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14065 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14066 });
14067 let include_session_bundles = selected_capabilities.iter().any(|capability| {
14068 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14069 });
14070 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14071 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14072 });
14073 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14074 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14075 });
14076 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14077 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14078 });
14079 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14080 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14081 });
14082 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14083 .try_acquire_owned()
14084 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14085 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14086 let _release_guard = ControllerReleaseGuard {
14087 shared: Arc::clone(&shared),
14088 connection_id,
14089 };
14090 drop(preauth_permit);
14091 let _authenticated_permit = authenticated_permit;
14092 let mut event_rx = shared.event_tx.subscribe();
14093 let mut terminal_event_rx = terminal_event_subscription(
14094 &shared,
14095 include_terminal_frame_events,
14096 );
14097 let mut agent_stream_event_rx = agent_stream_event_subscription(
14098 &shared,
14099 include_agent_stream_events,
14100 );
14101 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14102 &shared,
14103 include_harness_mcp_proxy,
14104 );
14105 write_json_frame(
14106 &mut pipe,
14107 &ServerFrame::Hello(NodeHello {
14108 build_stamp: BUILD_STAMP.to_owned(),
14109 incarnation_id: shared.incarnation_id,
14110 connection_id,
14111 role: hello.role,
14112 event_sequence: shared.current_sequence(),
14113 controller: shared.controller_state(),
14114 snapshot: snapshot_for_wire(
14115 &shared,
14116 include_provider_runtime_status,
14117 include_open_provider_ids,
14118 include_spawn_profiles,
14119 include_managed_worktrees,
14120 include_child_environment_profiles,
14121 include_session_bundles,
14122 include_history_context_packs,
14123 include_agent_progress,
14124 include_session_task_correlation,
14125 ),
14126 compatibility,
14127 }),
14128 )
14129 .await?;
14130 let (mut reader, mut writer) = tokio::io::split(pipe);
14131 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14132 let reader_task = tokio::spawn(async move {
14133 loop {
14134 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14135 &mut reader,
14136 MAX_NODE_CLIENT_FRAME_BYTES,
14137 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14138 )
14139 .await;
14140 let terminal = frame.is_err();
14141 if frame_tx.send(frame).await.is_err() || terminal {
14142 break;
14143 }
14144 }
14145 });
14146 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14147 let mut pending_events = Vec::new();
14148 let mut resync_required = false;
14149 let mut discard_events_through = 0_u64;
14150 loop {
14151 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14156 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14157 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14158 for _ in 0..durable_drain_budget {
14159 match event_rx.try_recv() {
14160 Ok(event) => queue_connection_event(
14161 &mut pending_events,
14162 event,
14163 discard_events_through,
14164 ),
14165 Err(broadcast::error::TryRecvError::Empty) => break,
14166 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14167 resync_required = true;
14168 break;
14169 }
14170 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14171 }
14172 }
14173 if let Some(receiver) = terminal_event_rx.as_mut() {
14174 match receiver.try_recv() {
14175 Ok(events) => queue_connection_event_batch(
14176 &mut pending_events,
14177 &events,
14178 discard_events_through,
14179 ),
14180 Err(broadcast::error::TryRecvError::Empty) => {}
14181 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14182 resync_required = true;
14183 }
14184 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14185 }
14186 }
14187 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14188 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14198 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14199 for _ in 0..agent_stream_drain_budget {
14200 match receiver.try_recv() {
14201 Ok(events) => queue_connection_event_batch(
14202 &mut pending_events,
14203 &events,
14204 discard_events_through,
14205 ),
14206 Err(broadcast::error::TryRecvError::Empty) => break,
14207 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14208 tracing::warn!(
14209 connection_id,
14210 dropped,
14211 "agent stream event subscription lagged; forcing a resync",
14212 );
14213 resync_required = true;
14214 break;
14215 }
14216 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14217 }
14218 }
14219 }
14220 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14221 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14227 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14228 for _ in 0..harness_mcp_proxy_drain_budget {
14229 match receiver.try_recv() {
14230 Ok(events) => queue_connection_event_batch(
14231 &mut pending_events,
14232 &events,
14233 discard_events_through,
14234 ),
14235 Err(broadcast::error::TryRecvError::Empty) => break,
14236 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14237 tracing::warn!(
14238 connection_id,
14239 dropped,
14240 "harness MCP proxy event subscription lagged; forcing a resync",
14241 );
14242 resync_required = true;
14243 break;
14244 }
14245 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14246 }
14247 }
14248 }
14249 if resync_required {
14250 let event = resync_required_event(&shared);
14251 apply_connection_resync_watermark(
14252 &mut pending_events,
14253 &mut discard_events_through,
14254 event.sequence,
14255 );
14256 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14257 resync_required = false;
14258 continue;
14259 }
14260 if !pending_events.is_empty() {
14261 pending_events.sort_unstable_by_key(|event| event.sequence);
14262 let burst_len = connection_event_burst_len(pending_events.len());
14263 shared
14264 .connection_events_sent
14265 .fetch_add(burst_len as u64, Ordering::Relaxed);
14266 for event in pending_events.drain(..burst_len) {
14267 let event = if include_managed_worktrees {
14268 Some(event)
14269 } else {
14270 project_event_without_managed_worktrees(event)
14271 };
14272 let event = event.and_then(|event| if include_open_provider_ids {
14273 Some(event)
14274 } else {
14275 project_event_legacy_provider_ids(&shared, event)
14276 });
14277 let event = event.map(|event| if include_child_environment_profiles {
14278 event
14279 } else {
14280 project_event_without_child_environment_profile(event)
14281 });
14282 let event = event.map(|event| if include_session_bundles {
14283 event
14284 } else {
14285 project_event_without_session_bundle(event)
14286 });
14287 let event = event.map(|event| {
14288 project_event_history_for_wire(event, include_history_context_packs)
14289 });
14290 let event = event.map(|event| if include_history_context_packs {
14291 event
14292 } else {
14293 project_event_without_context_pack(event)
14294 });
14295 let event = event.map(|event| if include_session_task_correlation {
14296 event
14297 } else {
14298 project_event_without_session_task_binding(event)
14299 });
14300 let exact_harness_controller = hello.role == ClientRole::Operator
14301 && shared.controller_state().is_some_and(|controller| {
14302 controller.connection_id == connection_id
14303 });
14304 let event = event.and_then(|event| {
14305 if include_harness_mcp_proxy && exact_harness_controller {
14306 Some(event)
14307 } else {
14308 project_event_without_harness_mcp_proxy(event)
14309 }
14310 });
14311 if let Some(event) = event {
14312 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14313 }
14314 }
14315 }
14316 tokio::select! {
14317 biased;
14318 frame = frame_rx.recv() => {
14319 let frame = match frame {
14320 Some(frame) => frame,
14321 None => break,
14322 };
14323 let frame = match frame {
14324 Ok(frame) => frame,
14325 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14326 Err(error) => return Err(error.into()),
14327 };
14328 let ClientFrame::Request(request) = frame else {
14329 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14330 };
14331 #[cfg(feature = "fixture")]
14332 let probe_spawn_managed_worktree_v2 = matches!(
14333 &request.request,
14334 NodeRequest::SpawnManagedWorktreeV2 { .. }
14335 );
14336 let requires_open_provider_ids =
14337 request_requires_open_provider_ids(&shared, &request.request);
14338 let requires_child_environment_profile =
14339 request_requires_child_environment_profile(&shared, &request.request);
14340 let requires_session_bundle =
14341 request_requires_session_bundle(&shared, &request.request);
14342 let requires_history_context_pack =
14343 request_requires_history_context_pack(&shared, &request.request);
14344 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14345 ResponseEnvelope {
14346 request_id: request.request_id,
14347 result: Err(failure(
14348 NodeFailureCode::UnsupportedCapability,
14349 "open provider IDs were not negotiated",
14350 )),
14351 }
14352 } else if requires_child_environment_profile
14353 && !include_child_environment_profiles
14354 {
14355 ResponseEnvelope {
14356 request_id: request.request_id,
14357 result: Err(failure(
14358 NodeFailureCode::UnsupportedCapability,
14359 "child environment profiles were not negotiated",
14360 )),
14361 }
14362 } else if requires_session_bundle && !include_session_bundles {
14363 ResponseEnvelope {
14364 request_id: request.request_id,
14365 result: Err(failure(
14366 NodeFailureCode::UnsupportedCapability,
14367 "session bundles were not negotiated",
14368 )),
14369 }
14370 } else if requires_history_context_pack && !include_history_context_packs {
14371 ResponseEnvelope {
14372 request_id: request.request_id,
14373 result: Err(failure(
14374 NodeFailureCode::UnsupportedCapability,
14375 "history context packs were not negotiated",
14376 )),
14377 }
14378 } else if request_uses_unnegotiated_capability(
14379 &request.request,
14380 &selected_capabilities,
14381 )
14382 {
14383 ResponseEnvelope {
14384 request_id: request.request_id,
14385 result: Err(failure(
14386 NodeFailureCode::UnsupportedCapability,
14387 "request capability was not negotiated",
14388 )),
14389 }
14390 } else {
14391 process_request(&shared, connection_id, hello.role, request).await
14392 };
14393 #[cfg(feature = "fixture")]
14394 if probe_spawn_managed_worktree_v2 {
14395 shared
14396 .fixture_spawn_managed_worktree_v2_failure_probe
14397 .record(reply.result.as_ref().err().map(|failure| failure.code));
14398 }
14399 if !include_provider_runtime_status {
14400 clear_response_provider_runtime_status(&mut reply);
14401 }
14402 if !include_managed_worktrees {
14403 project_response_without_managed_worktrees(&mut reply);
14404 }
14405 if !include_open_provider_ids {
14406 project_response_legacy_provider_ids(&shared, &mut reply);
14407 }
14408 if !include_child_environment_profiles {
14409 project_response_without_child_environment_profile(&mut reply);
14410 }
14411 if !include_session_bundles {
14412 project_response_without_session_bundle(&mut reply);
14413 }
14414 project_response_history_for_wire(
14415 &mut reply,
14416 include_history_context_packs,
14417 );
14418 if !include_history_context_packs {
14419 project_response_without_context_pack(&mut reply);
14420 }
14421 if !include_agent_progress {
14422 project_response_without_agent_progress(&mut reply);
14423 }
14424 if !include_session_task_correlation {
14425 project_response_without_session_task_binding(&mut reply);
14426 }
14427 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14428 }
14429 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14430 event = event_rx.recv() => {
14431 match event {
14432 Ok(event) => queue_connection_event(
14433 &mut pending_events,
14434 event,
14435 discard_events_through,
14436 ),
14437 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14438 Err(broadcast::error::RecvError::Closed) => break,
14439 }
14440 }
14441 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14442 match events {
14443 Ok(events) => queue_connection_event_batch(
14444 &mut pending_events,
14445 &events,
14446 discard_events_through,
14447 ),
14448 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14449 Err(broadcast::error::RecvError::Closed) => break,
14450 }
14451 }
14452 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14453 match events {
14454 Ok(events) => queue_connection_event_batch(
14455 &mut pending_events,
14456 &events,
14457 discard_events_through,
14458 ),
14459 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14460 Err(broadcast::error::RecvError::Closed) => break,
14461 }
14462 }
14463 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14464 match events {
14465 Ok(events) => queue_connection_event_batch(
14466 &mut pending_events,
14467 &events,
14468 discard_events_through,
14469 ),
14470 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14471 Err(broadcast::error::RecvError::Closed) => break,
14472 }
14473 }
14474 _ = shared.shutdown_notify.notified() => break,
14475 }
14476 }
14477 Ok(())
14478}
14479
14480async fn receive_terminal_event_batch(
14481 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14482) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14483 match receiver {
14484 Some(receiver) => receiver.recv().await,
14485 None => std::future::pending().await,
14486 }
14487}
14488
14489fn terminal_event_subscription(
14490 shared: &NodeShared,
14491 enabled: bool,
14492) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14493 enabled.then(|| shared.terminal_event_tx.subscribe())
14494}
14495
14496fn agent_stream_event_subscription(
14500 shared: &NodeShared,
14501 enabled: bool,
14502) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14503 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14504}
14505
14506fn harness_mcp_proxy_event_subscription(
14511 shared: &NodeShared,
14512 enabled: bool,
14513) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14514 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14515}
14516
14517fn queue_connection_event(
14518 pending: &mut Vec<NodeEventEnvelope>,
14519 event: NodeEventEnvelope,
14520 discard_through: u64,
14521) {
14522 if event.sequence <= discard_through
14523 && !event.event.requires_harness_mcp_proxy_capability()
14524 {
14525 return;
14526 }
14527 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14528 if let Some(index) = pending.iter().position(|current| {
14529 matches!(
14530 ¤t.event,
14531 NodeEvent::TerminalFrame {
14532 address: current_address,
14533 ..
14534 } if current_address == address
14535 )
14536 }) {
14537 pending.remove(index);
14538 }
14539 }
14540 pending.push(event);
14541}
14542
14543fn project_event_without_harness_mcp_proxy(
14544 envelope: NodeEventEnvelope,
14545) -> Option<NodeEventEnvelope> {
14546 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14547}
14548
14549fn queue_connection_event_batch(
14550 pending: &mut Vec<NodeEventEnvelope>,
14551 events: &[NodeEventEnvelope],
14552 discard_through: u64,
14553) {
14554 for event in events {
14555 queue_connection_event(pending, event.clone(), discard_through);
14556 }
14557}
14558
14559fn apply_connection_resync_watermark(
14560 pending: &mut Vec<NodeEventEnvelope>,
14561 discard_through: &mut u64,
14562 marker_sequence: u64,
14563) {
14564 *discard_through = (*discard_through).max(marker_sequence);
14565 pending.retain(|event| event.sequence > *discard_through);
14566}
14567
14568fn connection_event_burst_len(pending_len: usize) -> usize {
14569 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14570}
14571
14572fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14573 let history = shared
14574 .history
14575 .lock()
14576 .unwrap_or_else(|poisoned| poisoned.into_inner());
14577 let sequence = history.last_sequence;
14578 let oldest_available_sequence = history.replay_floor_sequence;
14579 NodeEventEnvelope {
14580 sequence,
14581 event: NodeEvent::ResyncRequired {
14582 oldest_available_sequence,
14583 },
14584 }
14585}
14586
14587fn node_compatibility_support(
14588 shared: &NodeShared,
14589) -> Result<NodeCompatibilitySupport, NodeServerError> {
14590 let mut support = node_compatibility_support_for_manifest(
14591 &shared.provider_contracts,
14592 &shared.provider_adapter_contracts,
14593 )?;
14594 if shared
14595 .delivery_store
14596 .lock()
14597 .unwrap_or_else(|poisoned| poisoned.into_inner())
14598 .is_some()
14599 {
14600 support.capabilities.push(
14601 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14602 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14603 );
14604 }
14605 if shared.harness_mcp_registry.is_some() {
14606 support.capabilities.push(
14607 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14608 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14609 );
14610 }
14611 Ok(support)
14612}
14613
14614fn node_compatibility_support_for_manifest(
14615 provider_contracts: &[ProviderContractSupport],
14616 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14617) -> Result<NodeCompatibilitySupport, NodeServerError> {
14618 Ok(NodeCompatibilitySupport {
14619 build_stamp: BUILD_STAMP.to_owned(),
14620 capabilities: baseline_capabilities()?,
14621 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14622 path_semantics: platform::path_semantics(),
14623 local_transport: platform::local_transport(),
14624 state_schema: StateSchemaSupport {
14625 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14626 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14627 },
14628 provider_contracts: provider_contracts.to_vec(),
14629 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14630 })
14631}
14632
14633fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14634 let capabilities = [
14635 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14636 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14637 NODE_REPOSITORY_PATH_CAPABILITY,
14638 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14639 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14640 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14641 NODE_GIT_READ_CAPABILITY,
14642 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14643 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14644 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14645 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14646 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14647 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14648 NODE_ACP_CONTROL_CAPABILITY,
14649 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14650 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14651 NODE_WORKTREE_SELECTION_CAPABILITY,
14652 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14653 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14654 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14655 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14656 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14657 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14658 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14659 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14660 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14661 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14662 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14663 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14664 ]
14665 .into_iter()
14666 .map(|capability| {
14667 CapabilityId::new(capability)
14668 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14669 })
14670 .collect::<Result<Vec<_>, _>>()?;
14671 #[cfg(windows)]
14672 let capabilities = {
14673 let mut capabilities = capabilities;
14674 capabilities.push(
14675 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14676 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14677 );
14678 capabilities
14679 };
14680 Ok(capabilities)
14681}
14682
14683fn request_uses_unnegotiated_capability(
14684 request: &NodeRequest,
14685 selected_capabilities: &[CapabilityId],
14686) -> bool {
14687 let selected = |required: &str| {
14688 selected_capabilities
14689 .iter()
14690 .any(|capability| capability.as_str() == required)
14691 };
14692 request
14693 .required_capability()
14694 .is_some_and(|required| !selected(required))
14695 || (request.requires_worktree_selection_capability()
14696 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14697 || (request.requires_spawn_spec_defaults_overrides_capability()
14698 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14699 || (request.requires_spawn_profile_revision_capability()
14700 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14701 || (request.requires_child_environment_profile_capability()
14702 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14703 || (request.requires_session_bundle_materialization_capability()
14704 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14705 || (request.requires_history_context_pack_capability()
14706 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14707}
14708
14709fn snapshot_for_wire(
14710 shared: &NodeShared,
14711 include_provider_runtime_status: bool,
14712 include_open_provider_ids: bool,
14713 include_spawn_profiles: bool,
14714 include_managed_worktrees: bool,
14715 include_child_environment_profiles: bool,
14716 include_session_bundles: bool,
14717 include_history_context_packs: bool,
14718 include_agent_progress: bool,
14719 include_session_task_correlation: bool,
14720) -> NodeSnapshot {
14721 let mut snapshot = shared.snapshot();
14722 if !include_provider_runtime_status {
14723 snapshot.provider_runtime_statuses.clear();
14724 }
14725 if !include_open_provider_ids {
14726 project_snapshot_legacy_provider_ids(&mut snapshot);
14727 }
14728 if !include_managed_worktrees {
14729 snapshot.managed_worktrees.clear();
14730 for workspace in &mut snapshot.workspaces {
14731 workspace.managed_worktree_profiles = None;
14732 }
14733 }
14734 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14735 if !include_spawn_profiles {
14736 inventory.spawn_profiles = None;
14737 }
14738 if !include_session_bundles {
14739 inventory.bundles = None;
14740 }
14741 if inventory.spawn_profiles.is_none()
14742 && inventory.bundles.is_none()
14743 && inventory.network_allowlists.is_none()
14744 {
14745 snapshot.launch_inventory = None;
14746 }
14747 }
14748 if !include_child_environment_profiles {
14749 clear_snapshot_child_environment_profiles(&mut snapshot);
14750 }
14751 if !include_session_bundles {
14752 clear_snapshot_session_bundles(&mut snapshot);
14753 }
14754 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14755 if !include_history_context_packs {
14756 clear_snapshot_context_packs(&mut snapshot);
14757 }
14758 if !include_agent_progress {
14759 snapshot.agent_progress.clear();
14760 }
14761 if !include_session_task_correlation {
14762 clear_snapshot_session_task_bindings(&mut snapshot);
14763 }
14764 snapshot
14765}
14766
14767fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14768 for record in &mut snapshot.session_records {
14769 record.task_binding = None;
14770 }
14771}
14772
14773fn project_event_without_session_task_binding(
14774 mut envelope: NodeEventEnvelope,
14775) -> NodeEventEnvelope {
14776 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14777 record.task_binding = None;
14778 }
14779 envelope
14780}
14781
14782fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14783 let Ok(response) = reply.result.as_mut() else { return };
14784 match response {
14785 NodeResponse::Snapshot { snapshot, .. } => {
14786 clear_snapshot_session_task_bindings(snapshot);
14787 }
14788 NodeResponse::Resync { snapshot, events, .. } => {
14789 clear_snapshot_session_task_bindings(snapshot);
14790 for event in events {
14791 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14792 record.task_binding = None;
14793 }
14794 }
14795 }
14796 NodeResponse::SessionRecordUpdated { record }
14797 | NodeResponse::ProviderSessionIndexed { record }
14798 | NodeResponse::NativeSessionIndexed { record, .. }
14799 | NodeResponse::SessionRecordResumed { record, .. } => {
14800 record.task_binding = None;
14801 }
14802 _ => {}
14803 }
14804}
14805
14806fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14807 let Ok(response) = reply.result.as_mut() else { return };
14808 match response {
14809 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14810 snapshot.agent_progress.clear();
14811 }
14812 _ => {}
14813 }
14814}
14815
14816fn project_event_without_managed_worktrees(
14817 mut envelope: NodeEventEnvelope,
14818) -> Option<NodeEventEnvelope> {
14819 if matches!(
14820 envelope.event,
14821 NodeEvent::ManagedWorktreeUpserted { .. }
14822 | NodeEvent::ManagedWorktreeRemoved { .. }
14823 ) {
14824 return None;
14825 }
14826 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14827 workspace.managed_worktree_profiles = None;
14828 }
14829 Some(envelope)
14830}
14831
14832fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14833 if matches!(
14834 reply.result,
14835 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14836 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14837 ) {
14838 reply.result = Err(failure(
14839 NodeFailureCode::UnsupportedCapability,
14840 "managed worktree capability was not negotiated",
14841 ));
14842 return;
14843 }
14844 let Ok(response) = reply.result.as_mut() else { return };
14845 match response {
14846 NodeResponse::Snapshot { snapshot, .. } => {
14847 snapshot.managed_worktrees.clear();
14848 for workspace in &mut snapshot.workspaces {
14849 workspace.managed_worktree_profiles = None;
14850 }
14851 }
14852 NodeResponse::Resync { snapshot, events, .. } => {
14853 snapshot.managed_worktrees.clear();
14854 for workspace in &mut snapshot.workspaces {
14855 workspace.managed_worktree_profiles = None;
14856 }
14857 *events = std::mem::take(events)
14858 .into_iter()
14859 .filter_map(project_event_without_managed_worktrees)
14860 .collect();
14861 }
14862 NodeResponse::WorkspaceRegistered { workspace }
14863 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14864 | NodeResponse::WorktreeCreated { workspace, .. } => {
14865 workspace.managed_worktree_profiles = None;
14866 }
14867 NodeResponse::WorkspaceInspected { inspection } => {
14868 inspection.git.managed_worktree = None;
14869 }
14870 _ => {}
14871 }
14872}
14873
14874fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14875 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14876 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14877 });
14878 if includes_open_provider_ids {
14879 return;
14880 }
14881 compatibility
14882 .provider_contracts
14883 .retain(|contract| provider_id_is_legacy(&contract.provider));
14884 compatibility
14885 .provider_adapter_contracts
14886 .retain(|contract| provider_id_is_legacy(&contract.provider));
14887}
14888
14889fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14890 if let Some(spec) = match request {
14891 NodeRequest::SpawnSpec { spec }
14892 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14893 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14894 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14895 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14896 _ => None,
14897 } {
14898 return shared
14899 .resolve_spawn_spec(spec)
14900 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14901 }
14902 request_requires_open_provider_ids_with(
14903 request,
14904 |session| shared.validate_address(session).ok(),
14905 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14906 )
14907}
14908
14909fn request_requires_child_environment_profile(
14910 shared: &NodeShared,
14911 request: &NodeRequest,
14912) -> bool {
14913 if let Some(spec) = match request {
14914 NodeRequest::SpawnSpec { spec }
14915 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14916 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14917 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14918 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14919 _ => None,
14920 } {
14921 return shared
14922 .resolve_spawn_spec(spec)
14923 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14924 }
14925 match request {
14926 NodeRequest::Resume { session, .. }
14927 | NodeRequest::Prompt { session, .. }
14928 | NodeRequest::Paste { session, .. }
14929 | NodeRequest::Input { session, .. }
14930 | NodeRequest::TerminalBytes { session, .. }
14931 | NodeRequest::TerminalControl { session, .. }
14932 | NodeRequest::Resize { session, .. }
14933 | NodeRequest::Interrupt { session }
14934 | NodeRequest::Stop { session, .. }
14935 | NodeRequest::Remove { session }
14936 | NodeRequest::ResolveInteraction { session, .. }
14937 | NodeRequest::SetSessionMode { session, .. }
14938 | NodeRequest::SetSessionConfigOption { session, .. }
14939 | NodeRequest::SetSessionModel { session, .. }
14940 | NodeRequest::DiscoverHistory { session, .. }
14941 | NodeRequest::LoadHistory { session, .. }
14942 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14943 | NodeRequest::ExportContextPack { session } => shared
14944 .session_bindings
14945 .lock()
14946 .unwrap_or_else(|poisoned| poisoned.into_inner())
14947 .get(&session.session.instance_id)
14948 .filter(|binding| {
14949 binding.workspace_id == session.workspace_id
14950 && binding.generation == session.session.generation
14951 })
14952 .map_or(true, |binding| binding.environment_profile.is_some()),
14953 NodeRequest::RenameSessionRecord { record_id, .. }
14954 | NodeRequest::SetSessionTask { record_id, .. }
14955 | NodeRequest::ResumeSessionRecord { record_id, .. }
14956 | NodeRequest::ForgetSessionRecord { record_id } => shared
14957 .record(record_id)
14958 .map_or(true, |record| record.environment_profile.is_some()),
14959 NodeRequest::ArmHarnessMcpReservation { .. }
14960 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14961 | NodeRequest::ActivateHarnessMcpReservation { .. }
14962 | NodeRequest::AbortHarnessMcpReservation { .. }
14963 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14964 | NodeRequest::RejectHarnessMcpCall { .. }
14965 | NodeRequest::Snapshot
14966 | NodeRequest::Resync { .. }
14967 | NodeRequest::BeginDeliveryStage { .. }
14968 | NodeRequest::PutDeliveryBlobChunk { .. }
14969 | NodeRequest::CommitDeliveryStage { .. }
14970 | NodeRequest::AbortDeliveryStage { .. }
14971 | NodeRequest::BrowseHostDirectories { .. }
14972 | NodeRequest::InspectWorkspace { .. }
14973 | NodeRequest::ReadWorkspaceFile { .. }
14974 | NodeRequest::WriteWorkspaceFile { .. }
14975 | NodeRequest::CreateWorkspaceFile { .. }
14976 | NodeRequest::CreateWorkspaceDirectory { .. }
14977 | NodeRequest::ReadGitHistory { .. }
14978 | NodeRequest::ReadGitDiff { .. }
14979 | NodeRequest::AcquireController { .. }
14980 | NodeRequest::ReleaseController
14981 | NodeRequest::Spawn { .. }
14982 | NodeRequest::SpawnWithOptions { .. }
14983 | NodeRequest::SpawnSpec { .. }
14984 | NodeRequest::RegisterWorkspace { .. }
14985 | NodeRequest::CreateStandaloneWorkspace { .. }
14986 | NodeRequest::UnregisterWorkspace { .. }
14987 | NodeRequest::CreateWorktree { .. }
14988 | NodeRequest::RemoveWorktree { .. }
14989 | NodeRequest::SpawnManagedWorktree { .. }
14990 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14991 | NodeRequest::CleanupManagedWorktree { .. }
14992 | NodeRequest::IndexProviderSession { .. }
14993 | NodeRequest::IndexNativeSession { .. }
14994 | NodeRequest::CatalogNativeSessions { .. }
14995 | NodeRequest::PageNativeSessions { .. }
14996 | NodeRequest::PreviewNativeSession { .. }
14997 | NodeRequest::PreviewSessionRecord { .. }
14998 | NodeRequest::ForgetContextPack { .. }
14999 | NodeRequest::ResolveDurableContextPack { .. }
15000 | NodeRequest::ReadContextPack { .. }
15001 | NodeRequest::Shutdown => false,
15002 }
15003}
15004
15005fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
15006 if let Some(spec) = match request {
15007 NodeRequest::SpawnSpec { spec }
15008 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15009 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15010 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15011 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15012 _ => None,
15013 } {
15014 return shared
15015 .resolve_spawn_spec(spec)
15016 .is_ok_and(|resolved| resolved.bundle_id.is_some());
15017 }
15018 match request {
15019 NodeRequest::Resume { session, .. }
15020 | NodeRequest::Prompt { session, .. }
15021 | NodeRequest::Paste { session, .. }
15022 | NodeRequest::Input { session, .. }
15023 | NodeRequest::TerminalBytes { session, .. }
15024 | NodeRequest::TerminalControl { session, .. }
15025 | NodeRequest::Resize { session, .. }
15026 | NodeRequest::Interrupt { session }
15027 | NodeRequest::Stop { session, .. }
15028 | NodeRequest::Remove { session }
15029 | NodeRequest::ResolveInteraction { session, .. }
15030 | NodeRequest::SetSessionMode { session, .. }
15031 | NodeRequest::SetSessionConfigOption { session, .. }
15032 | NodeRequest::SetSessionModel { session, .. }
15033 | NodeRequest::DiscoverHistory { session, .. }
15034 | NodeRequest::LoadHistory { session, .. }
15035 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15036 | NodeRequest::ExportContextPack { session } => shared
15037 .session_bindings
15038 .lock()
15039 .unwrap_or_else(|poisoned| poisoned.into_inner())
15040 .get(&session.session.instance_id)
15041 .filter(|binding| {
15042 binding.workspace_id == session.workspace_id
15043 && binding.generation == session.session.generation
15044 })
15045 .map_or(true, |binding| binding.bundle.is_some()),
15046 NodeRequest::RenameSessionRecord { record_id, .. }
15047 | NodeRequest::SetSessionTask { record_id, .. }
15048 | NodeRequest::ResumeSessionRecord { record_id, .. }
15049 | NodeRequest::ForgetSessionRecord { record_id } => shared
15050 .record(record_id)
15051 .map_or(true, |record| record.bundle.is_some()),
15052 NodeRequest::ArmHarnessMcpReservation { .. }
15053 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15054 | NodeRequest::ActivateHarnessMcpReservation { .. }
15055 | NodeRequest::AbortHarnessMcpReservation { .. }
15056 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15057 | NodeRequest::RejectHarnessMcpCall { .. }
15058 | NodeRequest::Snapshot
15059 | NodeRequest::Resync { .. }
15060 | NodeRequest::BeginDeliveryStage { .. }
15061 | NodeRequest::PutDeliveryBlobChunk { .. }
15062 | NodeRequest::CommitDeliveryStage { .. }
15063 | NodeRequest::AbortDeliveryStage { .. }
15064 | NodeRequest::BrowseHostDirectories { .. }
15065 | NodeRequest::InspectWorkspace { .. }
15066 | NodeRequest::ReadWorkspaceFile { .. }
15067 | NodeRequest::WriteWorkspaceFile { .. }
15068 | NodeRequest::CreateWorkspaceFile { .. }
15069 | NodeRequest::CreateWorkspaceDirectory { .. }
15070 | NodeRequest::ReadGitHistory { .. }
15071 | NodeRequest::ReadGitDiff { .. }
15072 | NodeRequest::AcquireController { .. }
15073 | NodeRequest::ReleaseController
15074 | NodeRequest::Spawn { .. }
15075 | NodeRequest::SpawnWithOptions { .. }
15076 | NodeRequest::SpawnSpec { .. }
15077 | NodeRequest::RegisterWorkspace { .. }
15078 | NodeRequest::CreateStandaloneWorkspace { .. }
15079 | NodeRequest::UnregisterWorkspace { .. }
15080 | NodeRequest::CreateWorktree { .. }
15081 | NodeRequest::RemoveWorktree { .. }
15082 | NodeRequest::SpawnManagedWorktree { .. }
15083 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15084 | NodeRequest::CleanupManagedWorktree { .. }
15085 | NodeRequest::IndexProviderSession { .. }
15086 | NodeRequest::IndexNativeSession { .. }
15087 | NodeRequest::CatalogNativeSessions { .. }
15088 | NodeRequest::PageNativeSessions { .. }
15089 | NodeRequest::PreviewNativeSession { .. }
15090 | NodeRequest::PreviewSessionRecord { .. }
15091 | NodeRequest::ForgetContextPack { .. }
15092 | NodeRequest::ResolveDurableContextPack { .. }
15093 | NodeRequest::ReadContextPack { .. }
15094 | NodeRequest::Shutdown => false,
15095 }
15096}
15097
15098fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15099 if request.requires_history_context_pack_capability() {
15100 return true;
15101 }
15102 if let Some(spec) = match request {
15103 NodeRequest::SpawnSpec { spec }
15104 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15105 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15106 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15107 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15108 _ => None,
15109 } {
15110 return shared
15111 .resolve_spawn_spec(spec)
15112 .map_or(true, |resolved| resolved.context_id.is_some());
15113 }
15114 match request {
15115 NodeRequest::Resume { session, .. }
15116 | NodeRequest::Prompt { session, .. }
15117 | NodeRequest::Paste { session, .. }
15118 | NodeRequest::Input { session, .. }
15119 | NodeRequest::TerminalBytes { session, .. }
15120 | NodeRequest::TerminalControl { session, .. }
15121 | NodeRequest::Resize { session, .. }
15122 | NodeRequest::Interrupt { session }
15123 | NodeRequest::Stop { session, .. }
15124 | NodeRequest::Remove { session }
15125 | NodeRequest::ResolveInteraction { session, .. }
15126 | NodeRequest::SetSessionMode { session, .. }
15127 | NodeRequest::SetSessionConfigOption { session, .. }
15128 | NodeRequest::SetSessionModel { session, .. } => shared
15129 .session_bindings
15130 .lock()
15131 .unwrap_or_else(|poisoned| poisoned.into_inner())
15132 .get(&session.session.instance_id)
15133 .filter(|binding| {
15134 binding.workspace_id == session.workspace_id
15135 && binding.generation == session.session.generation
15136 })
15137 .map_or(true, |binding| binding.context.is_some()),
15138 NodeRequest::RenameSessionRecord { record_id, .. }
15139 | NodeRequest::SetSessionTask { record_id, .. }
15140 | NodeRequest::ResumeSessionRecord { record_id, .. }
15141 | NodeRequest::ForgetSessionRecord { record_id } => shared
15142 .record(record_id)
15143 .map_or(true, |record| record.context.is_some()),
15144 NodeRequest::ArmHarnessMcpReservation { .. }
15145 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15146 | NodeRequest::ActivateHarnessMcpReservation { .. }
15147 | NodeRequest::AbortHarnessMcpReservation { .. }
15148 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15149 | NodeRequest::RejectHarnessMcpCall { .. }
15150 | NodeRequest::Snapshot
15151 | NodeRequest::Resync { .. }
15152 | NodeRequest::BeginDeliveryStage { .. }
15153 | NodeRequest::PutDeliveryBlobChunk { .. }
15154 | NodeRequest::CommitDeliveryStage { .. }
15155 | NodeRequest::AbortDeliveryStage { .. }
15156 | NodeRequest::BrowseHostDirectories { .. }
15157 | NodeRequest::InspectWorkspace { .. }
15158 | NodeRequest::ReadWorkspaceFile { .. }
15159 | NodeRequest::WriteWorkspaceFile { .. }
15160 | NodeRequest::CreateWorkspaceFile { .. }
15161 | NodeRequest::CreateWorkspaceDirectory { .. }
15162 | NodeRequest::ReadGitHistory { .. }
15163 | NodeRequest::ReadGitDiff { .. }
15164 | NodeRequest::AcquireController { .. }
15165 | NodeRequest::ReleaseController
15166 | NodeRequest::Spawn { .. }
15167 | NodeRequest::SpawnWithOptions { .. }
15168 | NodeRequest::SpawnSpec { .. }
15169 | NodeRequest::RegisterWorkspace { .. }
15170 | NodeRequest::CreateStandaloneWorkspace { .. }
15171 | NodeRequest::UnregisterWorkspace { .. }
15172 | NodeRequest::CreateWorktree { .. }
15173 | NodeRequest::RemoveWorktree { .. }
15174 | NodeRequest::SpawnManagedWorktree { .. }
15175 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15176 | NodeRequest::CleanupManagedWorktree { .. }
15177 | NodeRequest::IndexProviderSession { .. }
15178 | NodeRequest::IndexNativeSession { .. }
15179 | NodeRequest::CatalogNativeSessions { .. }
15180 | NodeRequest::PageNativeSessions { .. }
15181 | NodeRequest::PreviewNativeSession { .. }
15182 | NodeRequest::PreviewSessionRecord { .. }
15183 | NodeRequest::DiscoverHistory { .. }
15184 | NodeRequest::LoadHistory { .. }
15185 | NodeRequest::ExportContextPackForSessionRecord { .. }
15186 | NodeRequest::ExportContextPack { .. }
15187 | NodeRequest::ForgetContextPack { .. }
15188 | NodeRequest::ResolveDurableContextPack { .. }
15189 | NodeRequest::ReadContextPack { .. }
15190 | NodeRequest::Shutdown => false,
15191 }
15192}
15193
15194fn request_requires_open_provider_ids_with(
15195 request: &NodeRequest,
15196 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15197 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15198) -> bool {
15199 match request {
15200 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15201 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15202 NodeRequest::CatalogNativeSessions { route, .. }
15203 | NodeRequest::PageNativeSessions { route, .. } => {
15204 !provider_id_is_legacy(&route.provider)
15205 }
15206 NodeRequest::PreviewNativeSession { selection, .. }
15207 | NodeRequest::IndexNativeSession { selection, .. } => {
15208 !provider_id_is_legacy(&selection.route.provider)
15209 }
15210 NodeRequest::SpawnSpec { .. }
15211 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15212 NodeRequest::SpawnManagedWorktree { .. }
15213 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15214 NodeRequest::Resume { session, .. }
15215 | NodeRequest::Prompt { session, .. }
15216 | NodeRequest::Paste { session, .. }
15217 | NodeRequest::Input { session, .. }
15218 | NodeRequest::TerminalBytes { session, .. }
15219 | NodeRequest::TerminalControl { session, .. }
15220 | NodeRequest::Resize { session, .. }
15221 | NodeRequest::Interrupt { session }
15222 | NodeRequest::Stop { session, .. }
15223 | NodeRequest::Remove { session }
15224 | NodeRequest::ResolveInteraction { session, .. }
15225 | NodeRequest::SetSessionMode { session, .. }
15226 | NodeRequest::SetSessionConfigOption { session, .. }
15227 | NodeRequest::SetSessionModel { session, .. }
15228 | NodeRequest::DiscoverHistory { session, .. }
15229 | NodeRequest::LoadHistory { session, .. }
15230 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15231 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15232 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15233 NodeRequest::RenameSessionRecord { record_id, .. }
15234 | NodeRequest::SetSessionTask { record_id, .. }
15235 | NodeRequest::ResumeSessionRecord { record_id, .. }
15236 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15237 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15238 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15239 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15240 NodeRequest::ArmHarnessMcpReservation { .. }
15241 | NodeRequest::ActivateHarnessMcpReservation { .. }
15242 | NodeRequest::AbortHarnessMcpReservation { .. }
15243 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15244 | NodeRequest::RejectHarnessMcpCall { .. }
15245 | NodeRequest::Snapshot
15246 | NodeRequest::Resync { .. }
15247 | NodeRequest::BeginDeliveryStage { .. }
15248 | NodeRequest::PutDeliveryBlobChunk { .. }
15249 | NodeRequest::CommitDeliveryStage { .. }
15250 | NodeRequest::AbortDeliveryStage { .. }
15251 | NodeRequest::BrowseHostDirectories { .. }
15252 | NodeRequest::InspectWorkspace { .. }
15253 | NodeRequest::ReadWorkspaceFile { .. }
15254 | NodeRequest::WriteWorkspaceFile { .. }
15255 | NodeRequest::CreateWorkspaceFile { .. }
15256 | NodeRequest::CreateWorkspaceDirectory { .. }
15257 | NodeRequest::ReadGitHistory { .. }
15258 | NodeRequest::ReadGitDiff { .. }
15259 | NodeRequest::AcquireController { .. }
15260 | NodeRequest::ReleaseController
15261 | NodeRequest::RegisterWorkspace { .. }
15262 | NodeRequest::CreateStandaloneWorkspace { .. }
15263 | NodeRequest::UnregisterWorkspace { .. }
15264 | NodeRequest::CreateWorktree { .. }
15265 | NodeRequest::RemoveWorktree { .. }
15266 | NodeRequest::CleanupManagedWorktree { .. }
15267 | NodeRequest::Shutdown => false,
15268 NodeRequest::ForgetContextPack { .. } => true,
15269 NodeRequest::ResolveDurableContextPack { .. }
15275 | NodeRequest::ReadContextPack { .. } => true,
15276 }
15277}
15278
15279fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15280 snapshot.enabled_providers.retain(provider_id_is_legacy);
15281 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15282 snapshot
15283 .provider_runtime_statuses
15284 .as_slice()
15285 .iter()
15286 .filter(|status| provider_id_is_legacy(status.provider()))
15287 .cloned(),
15288 )
15289 .expect("a filtered provider runtime projection remains valid");
15290 snapshot
15291 .session_records
15292 .retain(|record| provider_id_is_legacy(&record.provider));
15293 for workspace in &mut snapshot.workspaces {
15294 workspace
15295 .sessions
15296 .retain(|session| provider_id_is_legacy(&session.agent_id));
15297 }
15298}
15299
15300fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15301 for record in &mut snapshot.session_records {
15302 record.environment_profile = None;
15303 }
15304 if let Some(profiles) = snapshot
15305 .launch_inventory
15306 .as_mut()
15307 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15308 {
15309 for profile in profiles {
15310 profile.environment_profile = None;
15311 }
15312 }
15313}
15314
15315fn project_event_without_child_environment_profile(
15316 mut envelope: NodeEventEnvelope,
15317) -> NodeEventEnvelope {
15318 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15319 record.environment_profile = None;
15320 }
15321 envelope
15322}
15323
15324fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15325 let contains_environment_profile = match reply.result.as_ref() {
15326 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15327 receipt.environment_profile.is_some()
15328 }
15329 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15330 receipt.spawn.environment_profile.is_some()
15331 }
15332 _ => false,
15333 };
15334 if contains_environment_profile {
15335 reply.result = Err(failure(
15336 NodeFailureCode::UnsupportedCapability,
15337 "child environment profiles were not negotiated",
15338 ));
15339 return;
15340 }
15341 let Ok(response) = reply.result.as_mut() else {
15342 return;
15343 };
15344 match response {
15345 NodeResponse::Snapshot { snapshot, .. } => {
15346 clear_snapshot_child_environment_profiles(snapshot);
15347 }
15348 NodeResponse::Resync { snapshot, events, .. } => {
15349 clear_snapshot_child_environment_profiles(snapshot);
15350 for event in events {
15351 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15352 record.environment_profile = None;
15353 }
15354 }
15355 }
15356 NodeResponse::SessionRecordUpdated { record }
15357 | NodeResponse::ProviderSessionIndexed { record }
15358 | NodeResponse::NativeSessionIndexed { record, .. }
15359 | NodeResponse::SessionRecordResumed { record, .. } => {
15360 record.environment_profile = None;
15361 }
15362 NodeResponse::Armed { .. }
15363 | NodeResponse::Spawned { .. }
15364 | NodeResponse::Activated { .. }
15365 | NodeResponse::Aborted { .. }
15366 | NodeResponse::ReplyChunkAccepted { .. }
15367 | NodeResponse::CallRejected { .. }
15368 | NodeResponse::DeliveryStageBegun { .. }
15369 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15370 | NodeResponse::DeliveryCommitted { .. }
15371 | NodeResponse::DeliveryStageAborted { .. }
15372 | NodeResponse::WorkspaceInspected { .. }
15373 | NodeResponse::HostDirectoriesBrowsed { .. }
15374 | NodeResponse::WorkspaceFileRead { .. }
15375 | NodeResponse::WorkspaceFileWritten { .. }
15376 | NodeResponse::WorkspaceFileCreated { .. }
15377 | NodeResponse::WorkspaceDirectoryCreated { .. }
15378 | NodeResponse::GitHistoryRead { .. }
15379 | NodeResponse::GitDiffRead { .. }
15380 | NodeResponse::Controller { .. }
15381 | NodeResponse::SpawnAccepted { .. }
15382 | NodeResponse::SpawnSpecAccepted { .. }
15383 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15384 | NodeResponse::ManagedWorktreeCleanup { .. }
15385 | NodeResponse::NativeSessionsCataloged { .. }
15386 | NodeResponse::NativeSessionsPaged { .. }
15387 | NodeResponse::NativeSessionPreviewed { .. }
15388 | NodeResponse::SessionRecordPreviewed { .. }
15389 | NodeResponse::HistoryDiscovered { .. }
15390 | NodeResponse::HistoryLoaded { .. }
15391 | NodeResponse::ContextPackForSessionRecordExported { .. }
15392 | NodeResponse::ContextPackExported { .. }
15393 | NodeResponse::ContextPackForgotten { .. }
15394 | NodeResponse::DurableContextPackResolved { .. }
15395 | NodeResponse::ContextPackBytesRead { .. }
15396 | NodeResponse::SessionRecordForgotten { .. }
15397 | NodeResponse::WorkspaceRegistered { .. }
15398 | NodeResponse::StandaloneWorkspaceCreated { .. }
15399 | NodeResponse::WorkspaceUnregistered { .. }
15400 | NodeResponse::WorktreeCreated { .. }
15401 | NodeResponse::WorktreeRemoved { .. }
15402 | NodeResponse::Accepted
15403 | NodeResponse::ShuttingDown => {}
15404 }
15405}
15406
15407fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15408 for record in &mut snapshot.session_records {
15409 record.bundle = None;
15410 }
15411}
15412
15413fn project_event_without_session_bundle(
15414 mut envelope: NodeEventEnvelope,
15415) -> NodeEventEnvelope {
15416 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15417 record.bundle = None;
15418 }
15419 envelope
15420}
15421
15422fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15423 let contains_bundle = match reply.result.as_ref() {
15424 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15425 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15426 receipt.spawn.bundle.is_some()
15427 }
15428 _ => false,
15429 };
15430 if contains_bundle {
15431 reply.result = Err(failure(
15432 NodeFailureCode::UnsupportedCapability,
15433 "session bundles were not negotiated",
15434 ));
15435 return;
15436 }
15437 let Ok(response) = reply.result.as_mut() else {
15438 return;
15439 };
15440 match response {
15441 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15442 NodeResponse::Resync { snapshot, events, .. } => {
15443 clear_snapshot_session_bundles(snapshot);
15444 for event in events {
15445 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15446 record.bundle = None;
15447 }
15448 }
15449 }
15450 NodeResponse::SessionRecordUpdated { record }
15451 | NodeResponse::ProviderSessionIndexed { record }
15452 | NodeResponse::NativeSessionIndexed { record, .. }
15453 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15454 NodeResponse::Armed { .. }
15455 | NodeResponse::Spawned { .. }
15456 | NodeResponse::Activated { .. }
15457 | NodeResponse::Aborted { .. }
15458 | NodeResponse::ReplyChunkAccepted { .. }
15459 | NodeResponse::CallRejected { .. }
15460 | NodeResponse::DeliveryStageBegun { .. }
15461 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15462 | NodeResponse::DeliveryCommitted { .. }
15463 | NodeResponse::DeliveryStageAborted { .. }
15464 | NodeResponse::WorkspaceInspected { .. }
15465 | NodeResponse::HostDirectoriesBrowsed { .. }
15466 | NodeResponse::WorkspaceFileRead { .. }
15467 | NodeResponse::WorkspaceFileWritten { .. }
15468 | NodeResponse::WorkspaceFileCreated { .. }
15469 | NodeResponse::WorkspaceDirectoryCreated { .. }
15470 | NodeResponse::GitHistoryRead { .. }
15471 | NodeResponse::GitDiffRead { .. }
15472 | NodeResponse::Controller { .. }
15473 | NodeResponse::SpawnAccepted { .. }
15474 | NodeResponse::SpawnSpecAccepted { .. }
15475 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15476 | NodeResponse::ManagedWorktreeCleanup { .. }
15477 | NodeResponse::NativeSessionsCataloged { .. }
15478 | NodeResponse::NativeSessionsPaged { .. }
15479 | NodeResponse::NativeSessionPreviewed { .. }
15480 | NodeResponse::SessionRecordPreviewed { .. }
15481 | NodeResponse::HistoryDiscovered { .. }
15482 | NodeResponse::HistoryLoaded { .. }
15483 | NodeResponse::ContextPackForSessionRecordExported { .. }
15484 | NodeResponse::ContextPackExported { .. }
15485 | NodeResponse::ContextPackForgotten { .. }
15486 | NodeResponse::DurableContextPackResolved { .. }
15487 | NodeResponse::ContextPackBytesRead { .. }
15488 | NodeResponse::SessionRecordForgotten { .. }
15489 | NodeResponse::WorkspaceRegistered { .. }
15490 | NodeResponse::StandaloneWorkspaceCreated { .. }
15491 | NodeResponse::WorkspaceUnregistered { .. }
15492 | NodeResponse::WorktreeCreated { .. }
15493 | NodeResponse::WorktreeRemoved { .. }
15494 | NodeResponse::Accepted
15495 | NodeResponse::ShuttingDown => {}
15496 }
15497}
15498
15499fn project_snapshot_history_for_wire(
15500 snapshot: &mut NodeSnapshot,
15501 include_history_metadata: bool,
15502) {
15503 for workspace in &mut snapshot.workspaces {
15504 for session in &mut workspace.sessions {
15505 if include_history_metadata {
15506 session.history.loaded = None;
15507 } else {
15508 session.history = Default::default();
15509 }
15510 }
15511 }
15512}
15513
15514fn project_event_history_for_wire(
15515 mut envelope: NodeEventEnvelope,
15516 include_history_metadata: bool,
15517) -> NodeEventEnvelope {
15518 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15519 for session in &mut workspace.sessions {
15520 if include_history_metadata {
15521 session.history.loaded = None;
15522 } else {
15523 session.history = Default::default();
15524 }
15525 }
15526 }
15527 envelope
15528}
15529
15530fn project_response_history_for_wire(
15531 reply: &mut ResponseEnvelope,
15532 include_history_metadata: bool,
15533) {
15534 let Ok(response) = reply.result.as_mut() else {
15535 return;
15536 };
15537 match response {
15538 NodeResponse::Snapshot { snapshot, .. } => {
15539 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15540 }
15541 NodeResponse::Resync {
15542 snapshot, events, ..
15543 } => {
15544 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15545 for event in events {
15546 *event = project_event_history_for_wire(
15547 event.clone(),
15548 include_history_metadata,
15549 );
15550 }
15551 }
15552 NodeResponse::WorkspaceRegistered { workspace }
15553 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15554 | NodeResponse::WorktreeCreated { workspace, .. } => {
15555 for session in &mut workspace.sessions {
15556 if include_history_metadata {
15557 session.history.loaded = None;
15558 } else {
15559 session.history = Default::default();
15560 }
15561 }
15562 }
15563 _ => {}
15564 }
15565}
15566
15567fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15568 for record in &mut snapshot.session_records {
15569 record.context_id = None;
15570 record.context = None;
15571 record.exported_context = None;
15572 }
15573}
15574
15575fn project_event_without_context_pack(
15576 mut envelope: NodeEventEnvelope,
15577) -> NodeEventEnvelope {
15578 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15579 record.context_id = None;
15580 record.context = None;
15581 record.exported_context = None;
15582 }
15583 envelope
15584}
15585
15586fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15587 let contains_context = match reply.result.as_ref() {
15588 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15589 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15590 receipt.spawn.context.is_some()
15591 }
15592 Ok(NodeResponse::HistoryDiscovered { .. })
15593 | Ok(NodeResponse::HistoryLoaded { .. })
15594 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15595 | Ok(NodeResponse::ContextPackExported { .. })
15596 | Ok(NodeResponse::ContextPackForgotten { .. })
15597 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15598 _ => false,
15599 };
15600 if contains_context {
15601 reply.result = Err(failure(
15602 NodeFailureCode::UnsupportedCapability,
15603 "history context packs were not negotiated",
15604 ));
15605 return;
15606 }
15607 let Ok(response) = reply.result.as_mut() else {
15608 return;
15609 };
15610 match response {
15611 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15612 NodeResponse::Resync {
15613 snapshot, events, ..
15614 } => {
15615 clear_snapshot_context_packs(snapshot);
15616 for event in events {
15617 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15618 record.context_id = None;
15619 record.context = None;
15620 record.exported_context = None;
15621 }
15622 }
15623 }
15624 NodeResponse::SessionRecordUpdated { record }
15625 | NodeResponse::ProviderSessionIndexed { record }
15626 | NodeResponse::NativeSessionIndexed { record, .. }
15627 | NodeResponse::SessionRecordResumed { record, .. } => {
15628 record.context_id = None;
15629 record.context = None;
15630 record.exported_context = None;
15631 }
15632 _ => {}
15633 }
15634}
15635
15636fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15637 let contains_open_record = match reply.result.as_ref() {
15638 Ok(NodeResponse::SessionRecordUpdated { record })
15639 | Ok(NodeResponse::ProviderSessionIndexed { record })
15640 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15641 !provider_id_is_legacy(&record.provider)
15642 }
15643 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15644 !provider_id_is_legacy(&selection.route.provider)
15645 || !provider_id_is_legacy(&record.provider)
15646 }
15647 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15648 !provider_id_is_legacy(&receipt.provider)
15649 }
15650 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15651 !provider_id_is_legacy(&receipt.spawn.provider)
15652 }
15653 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15654 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15655 !provider_id_is_legacy(&route.provider)
15656 }
15657 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15658 !provider_id_is_legacy(&selection.route.provider)
15659 }
15660 _ => false,
15661 };
15662 if contains_open_record {
15663 reply.result = Err(failure(
15664 NodeFailureCode::UnsupportedCapability,
15665 "open provider IDs were not negotiated",
15666 ));
15667 return;
15668 }
15669 let Ok(response) = reply.result.as_mut() else {
15670 return;
15671 };
15672 match response {
15673 NodeResponse::Snapshot { snapshot, .. } => {
15674 project_snapshot_legacy_provider_ids(snapshot);
15675 }
15676 NodeResponse::Resync { snapshot, events, .. } => {
15677 project_snapshot_legacy_provider_ids(snapshot);
15678 *events = std::mem::take(events)
15679 .into_iter()
15680 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15681 .collect();
15682 }
15683 NodeResponse::WorkspaceRegistered { workspace }
15684 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15685 | NodeResponse::WorktreeCreated { workspace, .. } => {
15686 workspace
15687 .sessions
15688 .retain(|session| provider_id_is_legacy(&session.agent_id));
15689 }
15690 NodeResponse::Armed { .. }
15691 | NodeResponse::Spawned { .. }
15692 | NodeResponse::Activated { .. }
15693 | NodeResponse::Aborted { .. }
15694 | NodeResponse::ReplyChunkAccepted { .. }
15695 | NodeResponse::CallRejected { .. }
15696 | NodeResponse::DeliveryStageBegun { .. }
15697 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15698 | NodeResponse::DeliveryCommitted { .. }
15699 | NodeResponse::DeliveryStageAborted { .. }
15700 | NodeResponse::WorkspaceInspected { .. }
15701 | NodeResponse::HostDirectoriesBrowsed { .. }
15702 | NodeResponse::WorkspaceFileRead { .. }
15703 | NodeResponse::WorkspaceFileWritten { .. }
15704 | NodeResponse::WorkspaceFileCreated { .. }
15705 | NodeResponse::WorkspaceDirectoryCreated { .. }
15706 | NodeResponse::GitHistoryRead { .. }
15707 | NodeResponse::GitDiffRead { .. }
15708 | NodeResponse::Controller { .. }
15709 | NodeResponse::SpawnAccepted { .. }
15710 | NodeResponse::SpawnSpecAccepted { .. }
15711 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15712 | NodeResponse::ManagedWorktreeCleanup { .. }
15713 | NodeResponse::NativeSessionsCataloged { .. }
15714 | NodeResponse::NativeSessionsPaged { .. }
15715 | NodeResponse::NativeSessionPreviewed { .. }
15716 | NodeResponse::SessionRecordPreviewed { .. }
15717 | NodeResponse::HistoryDiscovered { .. }
15718 | NodeResponse::HistoryLoaded { .. }
15719 | NodeResponse::ContextPackForSessionRecordExported { .. }
15720 | NodeResponse::ContextPackExported { .. }
15721 | NodeResponse::ContextPackForgotten { .. }
15722 | NodeResponse::DurableContextPackResolved { .. }
15723 | NodeResponse::ContextPackBytesRead { .. }
15724 | NodeResponse::SessionRecordUpdated { .. }
15725 | NodeResponse::ProviderSessionIndexed { .. }
15726 | NodeResponse::NativeSessionIndexed { .. }
15727 | NodeResponse::SessionRecordResumed { .. }
15728 | NodeResponse::SessionRecordForgotten { .. }
15729 | NodeResponse::WorkspaceUnregistered { .. }
15730 | NodeResponse::WorktreeRemoved { .. }
15731 | NodeResponse::Accepted
15732 | NodeResponse::ShuttingDown => {}
15733 }
15734}
15735
15736fn project_event_legacy_provider_ids(
15737 shared: &NodeShared,
15738 mut envelope: NodeEventEnvelope,
15739) -> Option<NodeEventEnvelope> {
15740 let include = match &mut envelope.event {
15741 NodeEvent::HarnessMcpReadCall { .. } => true,
15742 NodeEvent::Control { address, .. }
15743 | NodeEvent::AgentStream { address, .. }
15744 | NodeEvent::TerminalFrame { address, .. } => shared
15745 .validate_address(address)
15746 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15747 NodeEvent::WorkspaceAdded { workspace } => {
15748 workspace
15749 .sessions
15750 .retain(|session| provider_id_is_legacy(&session.agent_id));
15751 true
15752 }
15753 NodeEvent::SessionRecordUpserted { record } => {
15754 provider_id_is_legacy(&record.provider)
15755 }
15756 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15757 NodeEvent::SessionRecordRemoved { .. } => shared
15758 .history
15759 .lock()
15760 .unwrap_or_else(|poisoned| poisoned.into_inner())
15761 .removed_record_providers
15762 .get(&envelope.sequence)
15763 .is_some_and(provider_id_is_legacy),
15764 NodeEvent::ControllerChanged { .. }
15765 | NodeEvent::ManagedWorktreeUpserted { .. }
15766 | NodeEvent::ManagedWorktreeRemoved { .. }
15767 | NodeEvent::WorkspaceRemoved { .. }
15768 | NodeEvent::ResyncRequired { .. } => true,
15769 };
15770 include.then_some(envelope)
15771}
15772
15773fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15774 let Ok(response) = reply.result.as_mut() else {
15775 return;
15776 };
15777 match response {
15778 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15779 snapshot.provider_runtime_statuses.clear();
15780 }
15781 NodeResponse::Armed { .. }
15782 | NodeResponse::Spawned { .. }
15783 | NodeResponse::Activated { .. }
15784 | NodeResponse::Aborted { .. }
15785 | NodeResponse::ReplyChunkAccepted { .. }
15786 | NodeResponse::CallRejected { .. }
15787 | NodeResponse::DeliveryStageBegun { .. }
15788 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15789 | NodeResponse::DeliveryCommitted { .. }
15790 | NodeResponse::DeliveryStageAborted { .. }
15791 | NodeResponse::WorkspaceInspected { .. }
15792 | NodeResponse::HostDirectoriesBrowsed { .. }
15793 | NodeResponse::WorkspaceFileRead { .. }
15794 | NodeResponse::WorkspaceFileWritten { .. }
15795 | NodeResponse::WorkspaceFileCreated { .. }
15796 | NodeResponse::WorkspaceDirectoryCreated { .. }
15797 | NodeResponse::GitHistoryRead { .. }
15798 | NodeResponse::GitDiffRead { .. }
15799 | NodeResponse::Controller { .. }
15800 | NodeResponse::SpawnAccepted { .. }
15801 | NodeResponse::SpawnSpecAccepted { .. }
15802 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15803 | NodeResponse::ManagedWorktreeCleanup { .. }
15804 | NodeResponse::NativeSessionsCataloged { .. }
15805 | NodeResponse::NativeSessionsPaged { .. }
15806 | NodeResponse::NativeSessionPreviewed { .. }
15807 | NodeResponse::SessionRecordPreviewed { .. }
15808 | NodeResponse::HistoryDiscovered { .. }
15809 | NodeResponse::HistoryLoaded { .. }
15810 | NodeResponse::ContextPackForSessionRecordExported { .. }
15811 | NodeResponse::ContextPackExported { .. }
15812 | NodeResponse::ContextPackForgotten { .. }
15813 | NodeResponse::DurableContextPackResolved { .. }
15814 | NodeResponse::ContextPackBytesRead { .. }
15815 | NodeResponse::SessionRecordUpdated { .. }
15816 | NodeResponse::ProviderSessionIndexed { .. }
15817 | NodeResponse::NativeSessionIndexed { .. }
15818 | NodeResponse::SessionRecordResumed { .. }
15819 | NodeResponse::SessionRecordForgotten { .. }
15820 | NodeResponse::WorkspaceRegistered { .. }
15821 | NodeResponse::StandaloneWorkspaceCreated { .. }
15822 | NodeResponse::WorkspaceUnregistered { .. }
15823 | NodeResponse::WorktreeCreated { .. }
15824 | NodeResponse::WorktreeRemoved { .. }
15825 | NodeResponse::Accepted
15826 | NodeResponse::ShuttingDown => {}
15827 }
15828}
15829
15830struct ControllerReleaseGuard {
15831 shared: Arc<NodeShared>,
15832 connection_id: u64,
15833}
15834
15835impl Drop for ControllerReleaseGuard {
15836 fn drop(&mut self) {
15837 self.shared.release_controller(self.connection_id);
15838 }
15839}
15840
15841struct AbortTaskOnDrop(AbortHandle);
15842
15843impl Drop for AbortTaskOnDrop {
15844 fn drop(&mut self) {
15845 self.0.abort();
15846 }
15847}
15848
15849fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15854 std::env::var(name)
15855 .ok()
15856 .and_then(|value| value.parse::<u64>().ok())
15857 .filter(|value| *value > 0)
15858 .unwrap_or(default)
15859 .min(max)
15860}
15861
15862fn workspace_inspection_time_budget_ms() -> u64 {
15863 env_config_bounded(
15864 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15865 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15866 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15867 )
15868}
15869
15870fn workspace_inspection_entry_cap() -> usize {
15871 env_config_bounded(
15872 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15873 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15874 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15875 ) as usize
15876}
15877
15878struct WorkspaceWalkBudget {
15884 deadline: Instant,
15885 entry_cap: usize,
15886 entries_visited: u64,
15887 time_budget_exceeded: bool,
15888 entry_cap_exceeded: bool,
15889}
15890
15891impl WorkspaceWalkBudget {
15892 fn new(deadline: Instant, entry_cap: usize) -> Self {
15893 Self {
15894 deadline,
15895 entry_cap,
15896 entries_visited: 0,
15897 time_budget_exceeded: false,
15898 entry_cap_exceeded: false,
15899 }
15900 }
15901
15902 fn is_exhausted(&self) -> bool {
15903 self.time_budget_exceeded || self.entry_cap_exceeded
15904 }
15905
15906 fn record_visit(&mut self) -> bool {
15909 self.entries_visited += 1;
15910 if self.entries_visited as usize >= self.entry_cap {
15911 self.entry_cap_exceeded = true;
15912 }
15913 if Instant::now() >= self.deadline {
15914 self.time_budget_exceeded = true;
15915 }
15916 self.is_exhausted()
15917 }
15918}
15919
15920fn collect_workspace_entries(
15921 root: &Path,
15922 deadline: Instant,
15923 entry_cap: usize,
15924) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15925 let mut entries = Vec::new();
15926 let mut truncated = false;
15927 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15928 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15929 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15933 (entries, truncated, budget)
15934}
15935
15936fn workspace_inspection_truncation(
15940 walk_budget: &WorkspaceWalkBudget,
15941 git_time_budget_exceeded: bool,
15942 elapsed: Duration,
15943) -> Option<WorkspaceInspectionTruncationV1> {
15944 if !walk_budget.time_budget_exceeded
15945 && !walk_budget.entry_cap_exceeded
15946 && !git_time_budget_exceeded
15947 {
15948 return None;
15949 }
15950 Some(WorkspaceInspectionTruncationV1 {
15951 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15952 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15953 git_time_budget_exceeded,
15954 entries_visited: walk_budget.entries_visited,
15955 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15956 })
15957}
15958
15959fn walk_workspace_directory(
15960 directory: &Path,
15961 relative_directory: Option<&RepositoryPath>,
15962 depth: usize,
15963 entries: &mut Vec<WorkspaceEntry>,
15964 truncated: &mut bool,
15965 budget: &mut WorkspaceWalkBudget,
15966) {
15967 if budget.is_exhausted() {
15968 *truncated = true;
15969 return;
15970 }
15971 let read_dir = match std::fs::read_dir(directory) {
15972 Ok(read_dir) => read_dir,
15973 Err(_) => {
15974 *truncated = true;
15975 return;
15976 }
15977 };
15978 let mut children = Vec::new();
15979 for child in read_dir {
15980 match child {
15981 Ok(child) => children.push(child),
15982 Err(_) => *truncated = true,
15983 }
15984 }
15985 children.sort_by_key(|child| child.file_name());
15986 for child in children {
15987 if budget.record_visit() {
15988 *truncated = true;
15989 return;
15990 }
15991 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15992 *truncated = true;
15993 return;
15994 }
15995 let file_type = match child.file_type() {
15996 Ok(file_type) => file_type,
15997 Err(_) => {
15998 *truncated = true;
15999 continue;
16000 }
16001 };
16002 if file_type.is_symlink() {
16003 continue;
16004 }
16005 let name = match child.file_name().into_string() {
16006 Ok(name) => name,
16007 Err(_) => {
16008 *truncated = true;
16009 continue;
16010 }
16011 };
16012 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16013 continue;
16014 }
16015 let relative_path = match relative_directory {
16016 None => windows_repository_path(name),
16017 Some(parent) => parent
16018 .as_utf8()
16019 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16020 };
16021 let Some(relative_path) = relative_path else {
16022 *truncated = true;
16023 continue;
16024 };
16025 let kind = if file_type.is_dir() {
16026 WorkspaceEntryKind::Directory
16027 } else if file_type.is_file() {
16028 WorkspaceEntryKind::File
16029 } else {
16030 continue;
16031 };
16032 entries.push(WorkspaceEntry {
16033 relative_path: relative_path.clone(),
16034 kind,
16035 });
16036 if file_type.is_dir() {
16037 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16038 walk_workspace_directory(
16039 &child.path(),
16040 Some(&relative_path),
16041 depth + 1,
16042 entries,
16043 truncated,
16044 budget,
16045 );
16046 } else {
16047 *truncated = true;
16048 }
16049 }
16050 }
16051}
16052
16053fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16054 if value.contains('\\') {
16055 return None;
16056 }
16057 RepositoryPath::utf8(value).ok()
16058}
16059
16060fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16061 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16062}
16063
16064fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16073 if name.eq_ignore_ascii_case(".git")
16074 || name.eq_ignore_ascii_case("target")
16075 || name.eq_ignore_ascii_case("node_modules")
16076 {
16077 return true;
16078 }
16079 path.join("CACHEDIR.TAG").is_file()
16080}
16081
16082fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16083 matches!(
16084 status,
16085 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16086 )
16087}
16088
16089#[derive(Clone, Debug, Eq, PartialEq)]
16090enum ContextPackRepositoryHead {
16091 NotRepository,
16092 Unborn,
16093 Commit(String),
16094}
16095
16096#[derive(Clone, Debug, Eq, PartialEq)]
16097struct ContextPackRepositoryObservation {
16098 head: ContextPackRepositoryHead,
16099 git: GitSnapshot,
16100 files: Vec<ContextPackRepositoryFileSource>,
16101}
16102
16103fn stable_context_pack_repository(
16104 first: ContextPackRepositoryObservation,
16105 second: ContextPackRepositoryObservation,
16106) -> Result<ContextPackRepository, NodeFailure> {
16107 if first != second {
16108 return Err(failure(
16109 NodeFailureCode::ContextPackMaterializationFailed,
16110 "context repository changed during bounded capture",
16111 ));
16112 }
16113 Ok(ContextPackRepository::from_sources(
16114 &second.git,
16115 second.files,
16116 ))
16117}
16118
16119fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16126 if Instant::now() < deadline {
16127 return false;
16128 }
16129 append_git_diagnostic(
16130 snapshot,
16131 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16132 );
16133 true
16134}
16135
16136async fn run_git_bounded_with_deadline(
16145 root: &str,
16146 arguments: &[&str],
16147 output_limit: usize,
16148 deadline: Instant,
16149) -> io::Result<GitCommandOutput> {
16150 let remaining = deadline.checked_duration_since(Instant::now());
16151 let timeout_ms = remaining
16152 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16153 .unwrap_or(0)
16154 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16155 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16156}
16157
16158async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16165 let mut snapshot = GitSnapshot {
16166 is_repository: false,
16167 branch: None,
16168 status: Vec::new(),
16169 recent_commits: Vec::new(),
16170 worktrees: Vec::new(),
16171 managed_worktree: None,
16172 truncated: false,
16173 diagnostic: None,
16174 };
16175 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16176 return (snapshot, true);
16177 }
16178 let repository = match run_git_bounded_with_deadline(
16179 root,
16180 &["rev-parse", "--is-inside-work-tree"],
16181 4 * 1_024,
16182 deadline,
16183 )
16184 .await
16185 {
16186 Ok(output) => output,
16187 Err(error) => {
16188 append_git_diagnostic(
16189 &mut snapshot,
16190 &format!("git inspection unavailable: {error}"),
16191 );
16192 return (snapshot, false);
16193 }
16194 };
16195 snapshot.truncated |= repository.truncated;
16196 if repository.timed_out {
16197 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16198 return (snapshot, false);
16199 }
16200 if !repository.success
16201 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16202 {
16203 let stderr = String::from_utf8_lossy(&repository.stderr);
16204 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16205 append_git_diagnostic(&mut snapshot, stderr.trim());
16206 }
16207 return (snapshot, false);
16208 }
16209 snapshot.is_repository = true;
16210
16211 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16212 return (snapshot, true);
16213 }
16214 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16215 Ok(output) => {
16216 snapshot.truncated |= output.truncated;
16217 if output.timed_out {
16218 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16219 } else if output.success {
16220 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16221 if !branch.is_empty() {
16222 snapshot.branch = Some(truncate_git_field(
16223 &branch,
16224 MAX_REPOSITORY_PATH_BYTES,
16225 &mut snapshot.truncated,
16226 ));
16227 }
16228 }
16229 }
16230 Err(error) => append_git_diagnostic(
16231 &mut snapshot,
16232 &format!("git branch query failed: {error}"),
16233 ),
16234 }
16235 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16236 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16237 snapshot.truncated |= output.truncated;
16238 if output.success && !output.timed_out {
16239 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16240 if !head.is_empty() {
16241 snapshot.branch = Some(format!("detached:{head}"));
16242 }
16243 }
16244 }
16245 }
16246
16247 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16248 return (snapshot, true);
16249 }
16250 match run_git_bounded_with_deadline(
16251 root,
16252 &[
16253 "status",
16254 "--porcelain=v1",
16255 "-z",
16256 "--renames",
16257 "--untracked-files=normal",
16258 "--",
16259 ".",
16260 ],
16261 GIT_OUTPUT_MAX_BYTES,
16262 deadline,
16263 )
16264 .await
16265 {
16266 Ok(output) => {
16267 snapshot.truncated |= output.truncated;
16268 if output.timed_out {
16269 append_git_diagnostic(&mut snapshot, "git status timed out");
16270 } else if output.success {
16271 parse_git_status(&output.stdout, &mut snapshot);
16272 } else {
16273 append_git_diagnostic(
16274 &mut snapshot,
16275 String::from_utf8_lossy(&output.stderr).trim(),
16276 );
16277 }
16278 }
16279 Err(error) => append_git_diagnostic(
16280 &mut snapshot,
16281 &format!("git status failed: {error}"),
16282 ),
16283 }
16284
16285 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16286 return (snapshot, true);
16287 }
16288 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16289 match run_git_bounded_with_deadline(
16290 root,
16291 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16292 16 * 1_024,
16293 deadline,
16294 )
16295 .await
16296 {
16297 Ok(output) => {
16298 snapshot.truncated |= output.truncated;
16299 if output.timed_out {
16300 append_git_diagnostic(&mut snapshot, "git log timed out");
16301 } else if output.success {
16302 parse_git_commits(&output.stdout, &mut snapshot);
16303 } else {
16304 let stderr = String::from_utf8_lossy(&output.stderr);
16305 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16306 append_git_diagnostic(&mut snapshot, stderr.trim());
16307 }
16308 }
16309 }
16310 Err(error) => append_git_diagnostic(
16311 &mut snapshot,
16312 &format!("git log failed: {error}"),
16313 ),
16314 }
16315
16316 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16317 return (snapshot, true);
16318 }
16319 match list_git_worktrees_with_deadline(root, deadline).await {
16320 Ok(worktrees) => {
16321 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16322 }
16323 Err(error) => append_git_diagnostic(
16324 &mut snapshot,
16325 &format!("git worktree list failed: {}", error.message),
16326 ),
16327 }
16328 (snapshot, false)
16329}
16330
16331fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16332 let mut remaining = output;
16333 while !remaining.is_empty() {
16334 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16335 snapshot.truncated = true;
16336 break;
16337 }
16338 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16339 snapshot.truncated = true;
16340 break;
16341 };
16342 let record = &remaining[..record_end];
16343 remaining = &remaining[record_end + 1..];
16344 let has_previous_path = record
16345 .get(..2)
16346 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16347 let previous_path_bytes = if has_previous_path {
16348 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16349 snapshot.truncated = true;
16350 break;
16351 };
16352 let previous = &remaining[..previous_end];
16353 remaining = &remaining[previous_end + 1..];
16354 Some(previous)
16355 } else {
16356 None
16357 };
16358 if record.len() < 4
16359 || record[2] != b' '
16360 || !is_git_status_code(record[0])
16361 || !is_git_status_code(record[1])
16362 {
16363 snapshot.truncated = true;
16364 continue;
16365 }
16366 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16367 snapshot.truncated = true;
16368 continue;
16369 };
16370 let previous_path = match previous_path_bytes {
16371 Some(previous) => {
16372 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16373 snapshot.truncated = true;
16374 continue;
16375 };
16376 Some(previous)
16377 }
16378 None => None,
16379 };
16380 snapshot.status.push(GitStatusEntry {
16381 index_status: (record[0] as char).to_string(),
16382 worktree_status: (record[1] as char).to_string(),
16383 path,
16384 previous_path,
16385 });
16386 }
16387}
16388
16389fn is_git_status_code(byte: u8) -> bool {
16390 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16391}
16392
16393fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16394 for line in String::from_utf8_lossy(output).lines() {
16395 let Some((id, summary)) = line.split_once('\u{1f}') else {
16396 continue;
16397 };
16398 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16399 snapshot.truncated = true;
16400 break;
16401 }
16402 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16403 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16404 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16405 }
16406}
16407
16408fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16409 if value.len() <= max_bytes {
16410 return value.to_owned();
16411 }
16412 *truncated = true;
16413 let mut boundary = max_bytes;
16414 while boundary > 0 && !value.is_char_boundary(boundary) {
16415 boundary -= 1;
16416 }
16417 value[..boundary].to_owned()
16418}
16419
16420fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16421 if message.is_empty() {
16422 return;
16423 }
16424 let normalized = message
16425 .chars()
16426 .map(|character| if character.is_control() { ' ' } else { character })
16427 .collect::<String>();
16428 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16429 if !diagnostic.is_empty() {
16430 diagnostic.push_str("; ");
16431 }
16432 diagnostic.push_str(normalized.trim());
16433 snapshot.diagnostic = Some(truncate_git_field(
16434 &diagnostic,
16435 GIT_DIAGNOSTIC_MAX_BYTES,
16436 &mut snapshot.truncated,
16437 ));
16438}
16439
16440async fn run_git_bounded(
16441 root: &str,
16442 arguments: &[&str],
16443 output_limit: usize,
16444) -> io::Result<GitCommandOutput> {
16445 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16446}
16447
16448async fn read_git_history_bounded(
16449 root: &str,
16450 path: Option<&RepositoryPath>,
16451 before: Option<&GitObjectId>,
16452 limit: u16,
16453) -> Result<GitHistoryPage, NodeFailure> {
16454 let path = path
16455 .map(|path| {
16456 path.as_utf8().ok_or_else(|| {
16457 failure(
16458 NodeFailureCode::InvalidRepositoryPath,
16459 "git history path must use UTF-8",
16460 )
16461 })
16462 })
16463 .transpose()?
16464 .unwrap_or(".");
16465 let count = (usize::from(limit) + 1).to_string();
16466 let start = before
16467 .map(|revision| format!("{}^", revision.as_str()))
16468 .unwrap_or_else(|| "HEAD".to_owned());
16469 let arguments = [
16470 "--no-pager",
16471 "log",
16472 "--no-decorate",
16473 "--date=iso-strict",
16474 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16475 "-n",
16476 count.as_str(),
16477 start.as_str(),
16478 "--",
16479 path,
16480 ];
16481 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16482 .await
16483 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16484 if output.timed_out {
16485 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16486 }
16487 if !output.success {
16488 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16489 }
16490 let mut commits = parse_git_history(&output.stdout);
16491 let has_more = commits.len() > usize::from(limit) || output.truncated;
16492 commits.truncate(usize::from(limit));
16493 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16494 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16495}
16496
16497fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16498 String::from_utf8_lossy(output)
16499 .split('\u{1e}')
16500 .filter_map(|record| {
16501 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16502 if fields.len() != 9 {
16503 return None;
16504 }
16505 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16506 let parents = if fields[1].is_empty() {
16507 Vec::new()
16508 } else {
16509 fields[1]
16510 .split(' ')
16511 .map(|value| GitObjectId::new(value.to_owned()))
16512 .collect::<Result<Vec<_>, _>>()
16513 .ok()?
16514 };
16515 Some(GitCommitDetails {
16516 id,
16517 parents,
16518 subject: bounded_git_text(fields[2], 512),
16519 author_name: bounded_git_text(fields[3], 256),
16520 author_email: bounded_git_text(fields[4], 320),
16521 authored_at: bounded_git_text(fields[5], 64),
16522 committer_name: bounded_git_text(fields[6], 256),
16523 committer_email: bounded_git_text(fields[7], 320),
16524 committed_at: bounded_git_text(fields[8], 64),
16525 signature_status: GitSignatureStatus::NoSignature,
16526 signer: None,
16527 })
16528 })
16529 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16530 .collect()
16531}
16532
16533fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16534 let normalized = value
16535 .chars()
16536 .map(|character| if character.is_control() { ' ' } else { character })
16537 .collect::<String>();
16538 if normalized.len() <= max_bytes {
16539 return normalized;
16540 }
16541 let mut boundary = max_bytes;
16542 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16543 boundary -= 1;
16544 }
16545 normalized[..boundary].to_owned()
16546}
16547
16548async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16549 let path = request.path.as_ref().map(|path| {
16550 path.as_utf8()
16551 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16552 }).transpose()?;
16553 let mut arguments = match &request.mode {
16554 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16555 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16556 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16557 };
16558 if let GitDiffMode::Commit { revision } = &request.mode {
16559 arguments.push(revision.as_str());
16560 }
16561 arguments.push("--");
16562 if let Some(path) = path {
16563 arguments.push(path);
16564 }
16565 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16566 .await
16567 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16568 if output.timed_out {
16569 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16570 }
16571 if !output.success {
16572 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16573 }
16574 Ok(GitDiff {
16575 mode: request.mode,
16576 path: request.path,
16577 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16578 truncated: output.truncated,
16579 })
16580}
16581
16582async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16583 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16584 ResponseEnvelope { request_id: envelope.request_id, result }
16585}
16586
16587async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16588 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16589 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16590 }
16591 let read_only = matches!(
16592 &request,
16593 NodeRequest::Snapshot
16594 | NodeRequest::Resync { .. }
16595 | NodeRequest::BrowseHostDirectories { .. }
16596 | NodeRequest::InspectWorkspace { .. }
16597 | NodeRequest::ReadWorkspaceFile { .. }
16598 | NodeRequest::ReadGitHistory { .. }
16599 | NodeRequest::ReadGitDiff { .. }
16600 | NodeRequest::CatalogNativeSessions { .. }
16601 | NodeRequest::PageNativeSessions { .. }
16602 | NodeRequest::PreviewNativeSession { .. }
16603 | NodeRequest::PreviewSessionRecord { .. }
16604 );
16605 let _mutation_guard = if read_only {
16606 None
16607 } else {
16608 let guard = shared.mutation_gate.lock().await;
16609 if shared.shutdown.load(Ordering::Acquire) {
16610 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16611 }
16612 Some(guard)
16613 };
16614 match request {
16615 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16616 event_sequence: shared.current_sequence(),
16617 controller: shared.controller_state(),
16618 snapshot: shared.snapshot(),
16619 }),
16620 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16621 NodeRequest::ArmHarnessMcpReservation {
16622 reservation_id,
16623 activation_digest,
16624 spawn_spec,
16625 launch,
16626 expires_at_unix_ms,
16627 } => {
16628 shared.require_controller(connection_id, role)?;
16629 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16630 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16631 })?;
16632 let expires_at_unix_ms = registry
16633 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16634 .await
16635 .map_err(harness_mcp_failure)?;
16636 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16637 }
16638 NodeRequest::SpawnSpecWithHarnessMcp {
16639 reservation_id,
16640 activation_digest,
16641 spec,
16642 deadline_unix_ms,
16643 } => {
16644 shared.require_controller(connection_id, role)?;
16645 let receipt = shared
16646 .spawn_from_spec_with_harness_mcp(
16647 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16648 )
16649 .await?;
16650 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16651 }
16652 NodeRequest::ActivateHarnessMcpReservation {
16653 reservation_id,
16654 activation_digest,
16655 record_id,
16656 session,
16657 } => {
16658 shared.require_controller(connection_id, role)?;
16659 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16660 current.instance_id == session.session.instance_id
16661 && current.generation == session.session.generation
16662 }).and_then(|current| current.process_id).ok_or_else(|| {
16663 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16664 })?;
16665 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16666 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16667 })?.activate(
16668 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16669 ).map_err(harness_mcp_failure)?;
16670 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16671 }
16672 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16673 shared.require_controller(connection_id, role)?;
16674 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16675 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16676 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16677 if let Some(instance_id) = instance_id {
16678 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16679 }
16680 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16681 }
16682 NodeRequest::PutHarnessMcpReplyChunk {
16683 reservation_id, activation_digest, record_id, session, call_id,
16684 offset, final_chunk, chunk_hex,
16685 } => {
16686 shared.require_controller(connection_id, role)?;
16687 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16688 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16689 })?.put_reply_chunk(
16690 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16691 offset, final_chunk, &chunk_hex,
16692 ).map_err(harness_mcp_failure)?;
16693 Ok(NodeResponse::ReplyChunkAccepted {
16694 reservation_id, activation_digest, record_id, session, call_id,
16695 next_offset, completed,
16696 })
16697 }
16698 NodeRequest::RejectHarnessMcpCall {
16699 reservation_id, activation_digest, record_id, session, call_id, reason,
16700 } => {
16701 shared.require_controller(connection_id, role)?;
16702 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16703 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16704 })?.reject_call(
16705 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16706 ).map_err(harness_mcp_failure)?;
16707 Ok(NodeResponse::CallRejected {
16708 reservation_id, activation_digest, record_id, session, call_id,
16709 })
16710 }
16711 NodeRequest::BeginDeliveryStage { manifest } => {
16712 shared.require_controller(connection_id, role)?;
16713 shared.begin_delivery_stage(manifest)
16714 }
16715 NodeRequest::PutDeliveryBlobChunk {
16716 stage_id,
16717 blob_digest,
16718 offset,
16719 chunk_hex,
16720 } => {
16721 shared.require_controller(connection_id, role)?;
16722 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16723 }
16724 NodeRequest::CommitDeliveryStage { stage_id } => {
16725 shared.require_controller(connection_id, role)?;
16726 let receipt = shared.commit_delivery_stage(stage_id)?;
16727 Ok(NodeResponse::DeliveryCommitted { receipt })
16728 }
16729 NodeRequest::AbortDeliveryStage { stage_id } => {
16730 shared.require_controller(connection_id, role)?;
16731 shared.abort_delivery_stage(&stage_id)?;
16732 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16733 }
16734 NodeRequest::BrowseHostDirectories { directory, after } => {
16735 let listing = shared.browse_host_directories(directory, after).await?;
16736 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16737 }
16738 NodeRequest::InspectWorkspace { workspace_id } => {
16739 let inspection = shared.inspect_workspace(workspace_id).await?;
16740 Ok(NodeResponse::WorkspaceInspected { inspection })
16741 }
16742 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16743 let file = shared.read_workspace_file(workspace_id, path).await?;
16744 Ok(NodeResponse::WorkspaceFileRead { file })
16745 }
16746 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16747 shared.require_controller(connection_id, role)?;
16748 let file = shared
16749 .write_workspace_file(workspace_id, path, expected_revision, text)
16750 .await?;
16751 Ok(NodeResponse::WorkspaceFileWritten { file })
16752 }
16753 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16754 shared.require_controller(connection_id, role)?;
16755 let file = shared.create_workspace_file(workspace_id, path).await?;
16756 Ok(NodeResponse::WorkspaceFileCreated { file })
16757 }
16758 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16759 shared.require_controller(connection_id, role)?;
16760 let entry = shared
16761 .create_workspace_directory(workspace_id.clone(), path)
16762 .await?;
16763 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16764 }
16765 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16766 if role != ClientRole::Operator {
16767 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16768 }
16769 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16770 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16771 }
16772 let page = shared
16773 .read_git_history(workspace_id.clone(), path, before, limit)
16774 .await?;
16775 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16776 }
16777 NodeRequest::ReadGitDiff { workspace_id, request } => {
16778 if role != ClientRole::Operator {
16779 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16780 }
16781 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16782 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16783 }
16784 NodeRequest::CatalogNativeSessions { route, limit } => {
16785 if role != ClientRole::Operator {
16786 return Err(failure(
16787 NodeFailureCode::ObserverReadOnly,
16788 "native session catalog requires an operator connection",
16789 ));
16790 }
16791 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16792 return Err(failure(
16793 NodeFailureCode::InvalidRequest,
16794 "native session catalog limit is invalid",
16795 ));
16796 }
16797 let (entries, summary) = shared
16798 .catalog_native_sessions(route.clone(), limit)
16799 .await?;
16800 Ok(NodeResponse::NativeSessionsCataloged {
16801 route,
16802 entries,
16803 summary: Some(summary),
16804 })
16805 }
16806 NodeRequest::PageNativeSessions {
16807 route,
16808 window,
16809 catalog_revision,
16810 recent_cutoff_unix_ms,
16811 after_selection_id,
16812 limit,
16813 } => {
16814 if role != ClientRole::Operator {
16815 return Err(failure(
16816 NodeFailureCode::ObserverReadOnly,
16817 "native session catalog paging requires an operator connection",
16818 ));
16819 }
16820 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16821 || after_selection_id
16822 .as_deref()
16823 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16824 {
16825 return Err(failure(
16826 NodeFailureCode::InvalidRequest,
16827 "native session catalog page request is invalid",
16828 ));
16829 }
16830 let page = shared
16831 .page_native_sessions(
16832 route.clone(),
16833 window,
16834 catalog_revision,
16835 recent_cutoff_unix_ms,
16836 after_selection_id,
16837 limit,
16838 )
16839 .await?;
16840 Ok(NodeResponse::NativeSessionsPaged {
16841 route,
16842 page,
16843 })
16844 }
16845 NodeRequest::PreviewNativeSession {
16846 selection,
16847 message_limit,
16848 } => {
16849 if role != ClientRole::Operator {
16850 return Err(failure(
16851 NodeFailureCode::ObserverReadOnly,
16852 "native session preview requires an operator connection",
16853 ));
16854 }
16855 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16856 .contains(&message_limit)
16857 || selection.validate().is_err()
16858 {
16859 return Err(failure(
16860 NodeFailureCode::InvalidRequest,
16861 "native session preview request is invalid",
16862 ));
16863 }
16864 let preview = shared
16865 .preview_native_session(selection.clone(), message_limit)
16866 .await?;
16867 Ok(NodeResponse::NativeSessionPreviewed {
16868 selection,
16869 preview,
16870 })
16871 }
16872 NodeRequest::IndexNativeSession {
16873 selection,
16874 display_name,
16875 } => {
16876 shared.require_controller(connection_id, role)?;
16877 let record = shared
16878 .index_native_session(selection.clone(), display_name)
16879 .await?;
16880 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16881 }
16882 NodeRequest::PreviewSessionRecord {
16883 record_id,
16884 message_limit,
16885 } => {
16886 if role != ClientRole::Operator {
16887 return Err(failure(
16888 NodeFailureCode::ObserverReadOnly,
16889 "native session preview requires an operator connection",
16890 ));
16891 }
16892 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16893 .contains(&message_limit)
16894 {
16895 return Err(failure(
16896 NodeFailureCode::InvalidRequest,
16897 "native session preview request is invalid",
16898 ));
16899 }
16900 let record = shared.record(&record_id)?;
16901 let working_directory = shared.workspace_root(&record.workspace_id)?;
16902 if !platform::roots_equal(
16903 &working_directory,
16904 windows_path_text(&record.canonical_root),
16905 ) {
16906 return Err(failure(
16907 NodeFailureCode::SessionRecordConflict,
16908 "managed session workspace root changed; refusing to preview another directory",
16909 ));
16910 }
16911 let identity = record.provider_session.clone().ok_or_else(|| {
16912 failure(
16913 NodeFailureCode::BackendOperationFailed,
16914 "native session preview is unavailable for this session record",
16915 )
16916 })?;
16917 if identity.key != ProviderSessionKey::SessionId {
16918 return Err(failure(
16919 NodeFailureCode::BackendOperationFailed,
16920 "native session preview is unavailable for this session identity",
16921 ));
16922 }
16923 let preview = shared
16924 .preview_native_session_by_session_id(
16925 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16926 )
16927 .await?;
16928 if preview.session_id != identity.id {
16929 return Err(failure(
16930 NodeFailureCode::SessionRecordConflict,
16931 "native session preview identity changed while loading",
16932 ));
16933 }
16934 shared.revalidate_session_record_preview(&record, &identity)?;
16935 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16936 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16937 record_id: record_id.clone(),
16938 summary: SessionHistorySummaryV1::from(&preview),
16939 });
16940 Ok(NodeResponse::SessionRecordPreviewed {
16941 record_id,
16942 preview,
16943 })
16944 }
16945 NodeRequest::AcquireController { lease_ms } => {
16946 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16947 Ok(NodeResponse::Controller { controller: Some(controller) })
16948 }
16949 NodeRequest::ReleaseController => {
16950 shared.release_controller(connection_id);
16951 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16952 }
16953 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16954 shared.require_controller(connection_id, role)?;
16955 let root = require_windows_path(root)?;
16956 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16957 let workspace = shared.register_workspace(workspace_id, root).await?;
16958 Ok(NodeResponse::WorkspaceRegistered { workspace })
16959 }
16960 NodeRequest::CreateStandaloneWorkspace {
16961 workspace_id,
16962 root,
16963 initial_branch,
16964 } => {
16965 shared.require_controller(connection_id, role)?;
16966 let root = require_windows_path(root)?;
16967 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16968 let workspace = shared
16969 .create_standalone_workspace(workspace_id, root, initial_branch)
16970 .await?;
16971 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16972 }
16973 NodeRequest::UnregisterWorkspace { workspace_id } => {
16974 shared.require_controller(connection_id, role)?;
16975 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16976 shared.unregister_workspace(&workspace_id)?;
16977 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16978 }
16979 NodeRequest::CreateWorktree {
16980 source_workspace_id,
16981 workspace_id,
16982 target_root,
16983 branch,
16984 base,
16985 } => {
16986 shared.require_controller(connection_id, role)?;
16987 let target_root = require_windows_path(target_root)?;
16988 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16989 let (worktree, workspace) = shared
16990 .create_worktree(
16991 source_workspace_id,
16992 workspace_id,
16993 target_root,
16994 branch,
16995 base,
16996 )
16997 .await?;
16998 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16999 }
17000 NodeRequest::RemoveWorktree {
17001 source_workspace_id,
17002 target_root,
17003 } => {
17004 shared.require_controller(connection_id, role)?;
17005 let native_target_root = require_windows_path(target_root.clone())?;
17006 shared.reject_managed_reservation(None, Some(&native_target_root))?;
17007 let workspace_id = shared
17008 .remove_worktree(source_workspace_id, native_target_root)
17009 .await?;
17010 Ok(NodeResponse::WorktreeRemoved {
17011 target_root,
17012 workspace_id,
17013 })
17014 }
17015 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17016 shared.require_controller(connection_id, role)?;
17017 if !terminal_size.is_valid() {
17018 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17019 }
17020 if let Some(prompt) = initial_prompt.as_deref() {
17021 validate_node_text("spawn initial prompt", prompt)?;
17022 }
17023 match shared
17024 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17025 .await
17026 {
17027 Ok(session) => {
17028 shared.mark_spawn_provisional(session.clone());
17029 Ok(NodeResponse::SpawnAccepted { session })
17030 }
17031 Err(err) => {
17032 tracing::warn!(
17033 %provider,
17034 %workspace_id,
17035 ?mode,
17036 code = ?err.code,
17037 message = %err.message,
17038 "session spawn refused"
17039 );
17040 Err(err)
17041 }
17042 }
17043 }
17044 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17045 shared.require_controller(connection_id, role)?;
17046 if !terminal_size.is_valid() {
17047 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17048 }
17049 if let Some(prompt) = initial_prompt.as_deref() {
17050 validate_node_text("spawn initial prompt", prompt)?;
17051 }
17052 let plan = backend_paths::plan_launch_options(
17053 provider.as_str(),
17054 mode,
17055 options.model.is_some(),
17056 options.effort.is_some(),
17057 )
17058 .map_err(|r| {
17059 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17060 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17061 })?;
17062 let selection = spawn_selection(&options)?;
17063 let nick_model = selection.as_ref().map(|s| s.model.clone());
17064 let pty_selection = match plan {
17065 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17066 _ => None,
17067 };
17068 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17069 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17070 SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17071 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17072 .await
17073 }).await
17074 }).await;
17075 match spawned {
17076 Ok(session) => {
17077 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17078 shared
17079 .pending_acp_options
17080 .lock()
17081 .unwrap_or_else(|p| p.into_inner())
17082 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17083 }
17084 shared.mark_spawn_provisional(session.clone());
17085 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17086 Ok(NodeResponse::SpawnAccepted { session })
17087 }
17088 Err(err) => {
17089 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17090 Err(err)
17091 }
17092 }
17093 }
17094 NodeRequest::SpawnSpec { spec } => {
17095 shared.require_controller(connection_id, role)?;
17096 let receipt = shared.spawn_from_spec(spec).await?;
17097 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17098 }
17099 NodeRequest::SpawnManagedWorktree { request } => {
17100 shared.require_controller(connection_id, role)?;
17101 let receipt = shared.spawn_managed_worktree(request).await?;
17102 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17103 }
17104 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17105 shared.require_controller(connection_id, role)?;
17106 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17107 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17108 }
17109 NodeRequest::CleanupManagedWorktree { lease_id } => {
17110 shared.require_controller(connection_id, role)?;
17111 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17112 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17113 }
17114 NodeRequest::DiscoverHistory { session, limit } => {
17115 controlled_session(shared, connection_id, role, &session)?;
17116 let candidates = shared.discover_history(&session, limit).await?;
17117 Ok(NodeResponse::HistoryDiscovered {
17118 session,
17119 candidates,
17120 })
17121 }
17122 NodeRequest::LoadHistory {
17123 session,
17124 candidate_id,
17125 } => {
17126 controlled_session(shared, connection_id, role, &session)?;
17127 let loaded = shared
17128 .load_history(&session, candidate_id)
17129 .await?;
17130 Ok(NodeResponse::HistoryLoaded {
17131 session,
17132 session_id: loaded.session_id,
17133 message_count: loaded.message_count,
17134 completed_turn_count: loaded.completed_turn_count,
17135 })
17136 }
17137 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17138 shared.require_controller(connection_id, role)?;
17139 let context = shared
17140 .export_context_pack_for_session_record(&record_id, &session, false)
17141 .await?;
17142 Ok(NodeResponse::ContextPackForSessionRecordExported {
17143 record_id,
17144 session,
17145 context,
17146 })
17147 }
17148 NodeRequest::ExportContextPack { session } => {
17149 controlled_session(shared, connection_id, role, &session)?;
17150 let context = shared.export_context_pack(&session).await?;
17151 Ok(NodeResponse::ContextPackExported { context })
17152 }
17153 NodeRequest::ForgetContextPack { context_id } => {
17154 shared.require_controller(connection_id, role)?;
17155 shared.forget_context_pack(&context_id)?;
17156 Ok(NodeResponse::ContextPackForgotten { context_id })
17157 }
17158 NodeRequest::ResolveDurableContextPack { context_id } => {
17159 let context = shared
17163 .context_catalog
17164 .read()
17165 .unwrap_or_else(|poisoned| poisoned.into_inner())
17166 .get(&context_id)
17167 .map(|pack| pack.receipt().clone())
17168 .ok_or_else(|| {
17169 failure(
17170 NodeFailureCode::UnknownContextPack,
17171 "context pack is not present",
17172 )
17173 })?;
17174 Ok(NodeResponse::DurableContextPackResolved { context })
17175 }
17176 NodeRequest::ReadContextPack { digest } => {
17177 let (id, byte_len, bytes) = shared
17182 .context_catalog
17183 .read()
17184 .unwrap_or_else(|poisoned| poisoned.into_inner())
17185 .get_by_digest(&digest)
17186 .map(|pack| {
17187 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17188 })
17189 .ok_or_else(|| {
17190 failure(
17191 NodeFailureCode::UnknownContextPack,
17192 &format!("context pack {digest} not on this node"),
17193 )
17194 })?;
17195 tracing::debug!(
17196 digest = digest.as_str(),
17197 byte_len,
17198 "read context pack bytes",
17199 );
17200 Ok(NodeResponse::ContextPackBytesRead {
17201 pack: ContextPackBytesRead {
17202 digest,
17203 id,
17204 byte_len,
17205 bytes,
17206 },
17207 })
17208 }
17209 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17210 let provider = controlled_session(shared, connection_id, role, &session)?;
17211 if let Some(prompt) = initial_prompt.as_deref() {
17212 validate_node_text("resume initial prompt", prompt)?;
17213 }
17214 let runtime_requirement = if initial_prompt.is_some() {
17215 ProviderRuntimeRequirement::ResumeWithPrompt
17216 } else {
17217 ProviderRuntimeRequirement::Resume
17218 };
17219 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17220 let runtime_policy = shared
17221 .admit_provider_runtime(&provider, runtime_requirement)
17222 .await?;
17223 let working_directory = shared.bound_workspace_root(&session)?;
17224 let request = ResumeLaunchRequest {
17225 working_directory,
17226 terminal_size,
17227 initial_prompt,
17228 };
17229 request
17230 .validate()
17231 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17232 let command = shared.prepare_command(ControlCommand::Resume {
17233 instance_id: session.session.instance_id,
17234 target: ResumeTarget::CurrentProvider,
17235 runtime_policy,
17236 request,
17237 });
17238 shared.arm_resume(&session, command.id, runtime_policy)?;
17239 let dispatch = shared.dispatch_envelope(command);
17240 if let Err(error) = dispatch {
17241 shared.clear_armed_resume(&session);
17242 return Err(error);
17243 }
17244 Ok(NodeResponse::Accepted)
17245 }
17246 NodeRequest::RenameSessionRecord {
17247 record_id,
17248 display_name,
17249 } => {
17250 shared.require_controller(connection_id, role)?;
17251 let record = shared.rename_session_record(&record_id, display_name)?;
17252 Ok(NodeResponse::SessionRecordUpdated { record })
17253 }
17254 NodeRequest::SetSessionTask {
17255 record_id,
17256 expected_revision,
17257 target,
17258 } => {
17259 shared.require_controller(connection_id, role)?;
17260 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17261 Ok(NodeResponse::SessionRecordUpdated { record })
17262 }
17263 NodeRequest::IndexProviderSession {
17264 workspace_id,
17265 provider,
17266 identity,
17267 display_name,
17268 } => {
17269 shared.require_controller(connection_id, role)?;
17270 let record = shared.index_provider_session(
17271 workspace_id,
17272 provider,
17273 identity,
17274 display_name,
17275 )?;
17276 Ok(NodeResponse::ProviderSessionIndexed { record })
17277 }
17278 NodeRequest::ResumeSessionRecord {
17279 record_id,
17280 terminal_size,
17281 initial_prompt,
17282 } => {
17283 shared.require_controller(connection_id, role)?;
17284 if let Some(prompt) = initial_prompt.as_deref() {
17285 validate_node_text("managed resume initial prompt", prompt)?;
17286 }
17287 let (record, session) = shared
17288 .resume_session_record(
17289 &record_id,
17290 terminal_size,
17291 initial_prompt,
17292 )
17293 .await?;
17294 Ok(NodeResponse::SessionRecordResumed { record, session })
17295 }
17296 NodeRequest::ForgetSessionRecord { record_id } => {
17297 shared.require_controller(connection_id, role)?;
17298 shared.forget_session_record(&record_id).await?;
17299 Ok(NodeResponse::SessionRecordForgotten { record_id })
17300 }
17301 NodeRequest::Prompt { session, text } => {
17302 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17303 validate_node_text("prompt", &text)?;
17304 shared.require_session_runtime_policy(
17305 &session,
17306 ProviderRuntimeRequirement::SemanticPrompt,
17307 )?;
17308 let framing = prompt_framing(&agent_id);
17309 shared
17310 .dispatch_input_bounded(
17311 &session,
17312 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17313 )
17314 .await?;
17315 Ok(NodeResponse::Accepted)
17316 }
17317 NodeRequest::Paste { session, text } => {
17318 controlled_session(shared, connection_id, role, &session)?;
17319 validate_node_text("paste", &text)?;
17320 shared.require_session_runtime_policy(
17321 &session,
17322 ProviderRuntimeRequirement::SemanticPrompt,
17323 )?;
17324 shared
17325 .dispatch_input_bounded(
17326 &session,
17327 InputAction::InsertDraft(PromptPayload {
17328 text,
17329 framing: PromptFraming::BracketedPaste,
17330 }),
17331 )
17332 .await?;
17333 Ok(NodeResponse::Accepted)
17334 }
17335 NodeRequest::Input { session, text } => {
17336 controlled_session(shared, connection_id, role, &session)?;
17337 validate_node_text("terminal input", &text)?;
17338 shared
17339 .dispatch_input_bounded(
17340 &session,
17341 InputAction::TerminalText(TerminalText { text }),
17342 )
17343 .await?;
17344 Ok(NodeResponse::Accepted)
17345 }
17346 NodeRequest::TerminalBytes { session, bytes } => {
17347 controlled_session(shared, connection_id, role, &session)?;
17348 shared.sessionize_spawn(session.session.instance_id);
17349 let action = terminal_bytes_action(bytes)?;
17350 shared
17351 .dispatch_input_bounded(&session, action)
17352 .await?;
17353 Ok(NodeResponse::Accepted)
17354 }
17355 NodeRequest::TerminalControl { session, control } => {
17356 controlled_session(shared, connection_id, role, &session)?;
17357 shared
17358 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17359 .await?;
17360 Ok(NodeResponse::Accepted)
17361 }
17362 NodeRequest::Resize { session, size } => {
17363 controlled_session(shared, connection_id, role, &session)?;
17364 shared.sessionize_spawn(session.session.instance_id);
17365 if !size.is_valid() {
17366 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17367 }
17368 shared.dispatch_resize_bounded(&session, size).await?;
17369 Ok(NodeResponse::Accepted)
17370 }
17371 NodeRequest::Interrupt { session } => {
17372 controlled_session(shared, connection_id, role, &session)?;
17373 shared
17374 .dispatch_input_bounded(
17375 &session,
17376 InputAction::TerminalControl(TerminalControl::Interrupt),
17377 )
17378 .await?;
17379 Ok(NodeResponse::Accepted)
17380 }
17381 NodeRequest::Stop { session, force } => {
17382 controlled_session(shared, connection_id, role, &session)?;
17383 shared.sessionize_spawn(session.session.instance_id);
17384 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17385 shared.retire_session_grok_leader(session.session.instance_id);
17386 Ok(NodeResponse::Accepted)
17387 }
17388 NodeRequest::Remove { session } => {
17389 controlled_session(shared, connection_id, role, &session)?;
17390 shared.remove_session(&session).await?;
17391 Ok(NodeResponse::Accepted)
17392 }
17393 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17394 shared.require_controller(connection_id, role)?;
17395 let snapshot = shared.require_acp_session(&session)?;
17396 let interaction = snapshot
17397 .provider
17398 .interactions
17399 .iter()
17400 .find(|interaction| {
17401 interaction_correlation(
17402 session.session.instance_id,
17403 session.session.generation,
17404 interaction.id.0,
17405 ) == correlation_id
17406 })
17407 .ok_or_else(|| failure(
17408 NodeFailureCode::InvalidRequest,
17409 "unknown interaction correlation",
17410 ))?;
17411 if interaction.status != ProviderInteractionStatus::Pending {
17412 return Err(failure(
17413 NodeFailureCode::BackendOperationFailed,
17414 "interaction has already been resolved",
17415 ));
17416 }
17417 response
17418 .validate_for(interaction.interaction_kind)
17419 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17420 let interaction_id = interaction.id;
17421 let response_kind = response.kind();
17422 let response_outcome = response.outcome();
17423 shared.dispatch(ControlCommand::ResolveInteraction {
17424 instance_id: session.session.instance_id,
17425 generation: session.session.generation,
17426 interaction_id,
17427 response,
17428 })?;
17429 tracing::info!(
17435 node_id = %shared.node_id,
17436 session = ?session.session,
17437 correlation_id = %correlation_id,
17438 interaction_id = interaction_id.0,
17439 response_kind = ?response_kind,
17440 outcome = ?response_outcome,
17441 "interaction resolved",
17442 );
17443 Ok(NodeResponse::Accepted)
17444 }
17445 NodeRequest::SetSessionMode { session, mode_id } => {
17446 shared.require_controller(connection_id, role)?;
17447 shared.require_acp_session(&session)?;
17448 shared.dispatch(ControlCommand::SetSessionMode {
17449 instance_id: session.session.instance_id,
17450 mode_id,
17451 })?;
17452 Ok(NodeResponse::Accepted)
17453 }
17454 NodeRequest::SetSessionConfigOption {
17455 session,
17456 option_id,
17457 value_json,
17458 } => {
17459 shared.require_controller(connection_id, role)?;
17460 shared.require_acp_session(&session)?;
17461 shared.dispatch(ControlCommand::SetSessionConfigOption {
17462 instance_id: session.session.instance_id,
17463 option_id,
17464 value_json,
17465 })?;
17466 Ok(NodeResponse::Accepted)
17467 }
17468 NodeRequest::SetSessionModel { session, model_id } => {
17469 shared.require_controller(connection_id, role)?;
17470 shared.require_acp_session(&session)?;
17471 shared.dispatch(ControlCommand::SetSessionModel {
17472 instance_id: session.session.instance_id,
17473 model_id,
17474 })?;
17475 Ok(NodeResponse::Accepted)
17476 }
17477 NodeRequest::Shutdown => {
17478 shared.require_controller(connection_id, role)?;
17479 shared.begin_shutdown_locked().await?;
17480 Ok(NodeResponse::ShuttingDown)
17481 }
17482 }
17483}
17484
17485fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17486 shared.require_controller(connection_id, role)?;
17487 shared.validate_address(session)
17488}
17489
17490fn select_history_candidate(
17496 candidates: &[HistoryCandidateSummary],
17497 provider_session_id: Option<&str>,
17498) -> Result<String, NodeFailure> {
17499 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17500 Some(id) => candidate.session_id_hint == id,
17501 None => true,
17502 });
17503 let candidate = matches.next().ok_or_else(|| {
17504 failure(
17505 NodeFailureCode::SessionRecordConflict,
17506 if provider_session_id.is_some() {
17507 "no history candidate matches the managed provider session"
17508 } else {
17509 "no history candidate exists for the managed session"
17510 },
17511 )
17512 })?;
17513 if matches.next().is_some() {
17514 return Err(failure(
17515 NodeFailureCode::SessionRecordConflict,
17516 if provider_session_id.is_some() {
17517 "history identity is ambiguous for the managed provider session"
17518 } else {
17519 "history is ambiguous without a verified provider session identity"
17520 },
17521 ));
17522 }
17523 Ok(candidate.id.clone())
17524}
17525
17526fn resolved_spawn_profile_summary(
17527 profile: &SpawnProfileDefaults,
17528 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17529) -> Option<SpawnProfileSummary> {
17530 let environment_profile = match profile.environment_profile_id.as_ref() {
17531 None => None,
17532 Some(profile_id) => {
17533 let binding = environment_profiles.get(profile_id)?;
17534 if binding.provider != profile.provider
17535 || binding.native_profile_id(profile.mode).is_none()
17536 {
17537 return None;
17538 }
17539 Some(ResolvedEnvironmentProfileReceipt {
17540 profile_id: binding.id.clone(),
17541 profile_revision: binding.revision.clone(),
17542 network_allowlist: None,
17543 browser_profile_id: None,
17544 })
17545 }
17546 };
17547 Some(SpawnProfileSummary {
17548 id: profile.profile_id.clone(),
17549 revision: profile.revision.clone(),
17550 environment_profile,
17551 })
17552}
17553
17554fn session_record_export_target_matches(
17573 expected: &ManagedSessionRecord,
17574 current: &ManagedSessionRecord,
17575 session: &SessionAddress,
17576 allow_clean_detachment: bool,
17577) -> bool {
17578 if current == expected && current.active_session.as_ref() == Some(session) {
17579 return current.state == ManagedSessionState::Live;
17580 }
17581 allow_clean_detachment
17582 && current.state == ManagedSessionState::Dormant
17583 && current.active_session.is_none()
17584 && current.record_id == expected.record_id
17585 && current.display_name == expected.display_name
17586 && current.provider == expected.provider
17587 && current.mode == expected.mode
17588 && current.workspace_id == expected.workspace_id
17589 && current.canonical_root == expected.canonical_root
17590 && current.provider_session == expected.provider_session
17591 && current.environment_profile == expected.environment_profile
17592 && current.bundle == expected.bundle
17593 && current.context_id == expected.context_id
17594 && current.context == expected.context
17595 && current.exported_context == expected.exported_context
17596 && current.task_binding == expected.task_binding
17597 && current.created_at_unix_ms == expected.created_at_unix_ms
17598 && current.last_error == expected.last_error
17599}
17600
17601fn session_record_context_export_binding_is_exact(
17602 expected: &ManagedSessionRecord,
17603 current: &ManagedSessionRecord,
17604 identity: Option<&ProviderSessionIdentity>,
17605 session: &SessionAddress,
17606 binding: &SessionBinding,
17607 provider: &AgentId,
17608 current_root: &str,
17609 allow_clean_detachment: bool,
17610) -> bool {
17611 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17612 && current.provider_session.as_ref() == identity
17613 && current.workspace_id == session.workspace_id
17614 && provider == ¤t.provider
17615 && binding.workspace_id == session.workspace_id
17616 && binding.generation == session.session.generation
17617 && binding.record_id.as_ref() == Some(¤t.record_id)
17618 && platform::roots_equal(
17619 current_root,
17620 windows_path_text(¤t.canonical_root),
17621 )
17622}
17623
17624fn session_record_context_export_source_is_usable(
17625 record: &ManagedSessionRecord,
17626 status: &SessionStatus,
17627 allow_clean_detachment: bool,
17628) -> bool {
17629 (record.state == ManagedSessionState::Live
17630 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17631 && context_pack_source_status_is_usable(status)
17632}
17633
17634fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17635 PromptFraming::BracketedPaste
17636}
17637
17638fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17639 if text.len() > MAX_NODE_TEXT_BYTES {
17640 return Err(failure(
17641 NodeFailureCode::InvalidRequest,
17642 &format!(
17643 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17644 text.len(),
17645 ),
17646 ));
17647 }
17648 Ok(())
17649}
17650
17651fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17652 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17653 return Err(failure(
17654 NodeFailureCode::InvalidRequest,
17655 &format!(
17656 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17657 bytes.len(),
17658 ),
17659 ));
17660 }
17661 Ok(())
17662}
17663
17664fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17665 validate_terminal_bytes(&bytes)?;
17666 Ok(InputAction::TerminalBytes(bytes))
17667}
17668
17669fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17670 if root.is_empty()
17671 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17672 || root.chars().any(char::is_control)
17673 {
17674 return Err(failure(
17675 NodeFailureCode::InvalidWorkspaceRoot,
17676 &format!(
17677 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17678 ),
17679 ));
17680 }
17681 Ok(())
17682}
17683
17684fn canonical_standalone_workspace_candidate(
17685 workspace_id: &WorkspaceId,
17686 root: &str,
17687) -> Result<String, NodeServerError> {
17688 let path = Path::new(root);
17689 match std::fs::symlink_metadata(path) {
17690 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17691 .map(|workspace| workspace.canonical_root),
17692 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17693 if !path.is_absolute() {
17694 return Err(NodeServerError::InvalidWorkspaceRoot {
17695 workspace_id: workspace_id.clone(),
17696 path: root.to_owned(),
17697 message: "path is not absolute".to_owned(),
17698 });
17699 }
17700 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17701 workspace_id: workspace_id.clone(),
17702 path: root.to_owned(),
17703 message: "path has no standalone directory name".to_owned(),
17704 })?;
17705 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17706 workspace_id: workspace_id.clone(),
17707 path: root.to_owned(),
17708 message: "path has no parent directory".to_owned(),
17709 })?;
17710 let canonical = std::fs::canonicalize(parent)
17711 .map(|parent| parent.join(name))
17712 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17713 workspace_id: workspace_id.clone(),
17714 path: root.to_owned(),
17715 message: error.to_string(),
17716 })?;
17717 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17718 NodeServerError::InvalidWorkspaceRoot {
17719 workspace_id: workspace_id.clone(),
17720 path: path.to_string_lossy().into_owned(),
17721 message: "canonical path is not valid Unicode".to_owned(),
17722 }
17723 })?;
17724 let canonical_root = platform::normalize_canonical_root(canonical_root);
17725 validate_workspace_root(workspace_id, &canonical_root)?;
17726 Ok(canonical_root)
17727 }
17728 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17729 workspace_id: workspace_id.clone(),
17730 path: root.to_owned(),
17731 message: error.to_string(),
17732 }),
17733 }
17734}
17735
17736fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17737 let Some(revision) = revision else {
17738 return Ok(());
17739 };
17740 if revision.is_empty()
17741 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17742 || revision.chars().any(char::is_control)
17743 {
17744 return Err(failure(
17745 NodeFailureCode::InvalidRequest,
17746 &format!(
17747 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17748 ),
17749 ));
17750 }
17751 Ok(())
17752}
17753
17754fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17755 let code = match error.kind {
17756 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17757 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17758 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17759 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17760 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17761 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17762 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17763 };
17764 failure(code, &error.message)
17765}
17766
17767fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17768 let code = match error.kind {
17769 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17770 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17771 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17772 NodeFailureCode::BackendOperationFailed
17773 }
17774 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17775 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17776 }
17777 };
17778 failure(code, error.message)
17779}
17780
17781fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17782 let (code, message) = match error.kind {
17783 GitWorktreeErrorKind::Invalid => (
17784 NodeFailureCode::InvalidRequest,
17785 "managed Git input is invalid",
17786 ),
17787 GitWorktreeErrorKind::NotRepository => (
17788 NodeFailureCode::NotGitRepository,
17789 "managed source is not a Git repository",
17790 ),
17791 GitWorktreeErrorKind::Conflict => (
17792 NodeFailureCode::WorktreeConflict,
17793 "managed Git identity conflicts with existing repository state",
17794 ),
17795 GitWorktreeErrorKind::Protected => (
17796 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17797 "managed worktree ownership validation failed",
17798 ),
17799 GitWorktreeErrorKind::Dirty => (
17800 NodeFailureCode::WorktreeDirty,
17801 "managed worktree contains uncommitted changes",
17802 ),
17803 GitWorktreeErrorKind::Locked => (
17804 NodeFailureCode::WorktreeLocked,
17805 "managed worktree is locked",
17806 ),
17807 GitWorktreeErrorKind::Failed => (
17808 NodeFailureCode::BackendOperationFailed,
17809 "managed Git operation failed",
17810 ),
17811 };
17812 NodeFailure { code, message: message.to_owned() }
17813}
17814
17815fn managed_spawn_receipt(
17816 resolved: &ResolvedSpawnSpec,
17817 incarnation_id: NodeIncarnationId,
17818 session: SessionAddress,
17819 lease: ManagedWorktreeLeaseSnapshot,
17820 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17821 bundle: Option<ResolvedBundleReceipt>,
17822 context: Option<ResolvedContextPackReceipt>,
17823) -> ManagedWorktreeSpawnReceipt {
17824 let mut managed = resolved.clone();
17825 managed.target.worktree_id = Some(lease.workspace_id.clone());
17826 ManagedWorktreeSpawnReceipt {
17827 spawn: managed.receipt_with_materialization(
17828 incarnation_id,
17829 session,
17830 environment_profile,
17831 bundle,
17832 context,
17833 ),
17834 lease,
17835 }
17836}
17837
17838fn validate_selected_worktree(
17839 source_root: &str,
17840 selected_root: &str,
17841 worktrees: &[NativeGitWorktreeSnapshot],
17842) -> Result<(), NodeFailure> {
17843 if !worktrees
17844 .iter()
17845 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17846 {
17847 return Err(failure(
17848 NodeFailureCode::WorktreeProtected,
17849 "source workspace is not an authoritative non-bare Git worktree root",
17850 ));
17851 }
17852 if !worktrees.iter().any(|worktree| {
17853 worktree_paths_equal(&worktree.path, selected_root)
17854 && !worktree.is_main
17855 && !worktree.is_bare
17856 }) {
17857 return Err(failure(
17858 NodeFailureCode::WorktreeProtected,
17859 "selected workspace is not an eligible Git worktree in the source repository",
17860 ));
17861 }
17862 Ok(())
17863}
17864
17865fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17866 let code = match error.kind() {
17867 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17868 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17869 NodeFailureCode::RepositoryPathUnsafe
17870 }
17871 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17872 WorkspaceFileReadErrorKind::NotRegularFile => {
17873 NodeFailureCode::RepositoryFileNotRegular
17874 }
17875 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17876 NodeFailureCode::RepositoryFileReadFailed
17877 }
17878 WorkspaceFileReadErrorKind::RevisionConflict => {
17879 NodeFailureCode::RepositoryFileRevisionConflict
17880 }
17881 WorkspaceFileReadErrorKind::AlreadyExists
17882 | WorkspaceFileReadErrorKind::ParentNotFound
17883 | WorkspaceFileReadErrorKind::ParentNotDirectory
17884 | WorkspaceFileReadErrorKind::Canceled => {
17885 NodeFailureCode::RepositoryFileReadFailed
17886 }
17887 };
17888 failure(code, "repository file read failed")
17889}
17890
17891fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17892 let code = match error.kind() {
17893 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17894 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17895 NodeFailureCode::RepositoryPathUnsafe
17896 }
17897 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17898 WorkspaceFileReadErrorKind::NotRegularFile => {
17899 NodeFailureCode::RepositoryFileNotRegular
17900 }
17901 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17902 NodeFailureCode::RepositoryFileWriteFailed
17903 }
17904 WorkspaceFileReadErrorKind::RevisionConflict => {
17905 NodeFailureCode::RepositoryFileRevisionConflict
17906 }
17907 WorkspaceFileReadErrorKind::AlreadyExists
17908 | WorkspaceFileReadErrorKind::ParentNotFound
17909 | WorkspaceFileReadErrorKind::ParentNotDirectory
17910 | WorkspaceFileReadErrorKind::Canceled => {
17911 NodeFailureCode::RepositoryFileWriteFailed
17912 }
17913 };
17914 failure(code, "repository file write failed")
17915}
17916
17917async fn settle_workspace_entry_create<T>(
17918 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17919 commit_state: Arc<AtomicU8>,
17920 deadline: Duration,
17921 timeout_message: &'static str,
17922 task_failure_message: &'static str,
17923) -> Result<T, NodeFailure> {
17924 let joined = match timeout(deadline, &mut task).await {
17925 Ok(joined) => joined,
17926 Err(_) => {
17927 if commit_state
17928 .compare_exchange(
17929 WORKSPACE_ENTRY_CREATE_PENDING,
17930 WORKSPACE_ENTRY_CREATE_CANCELED,
17931 Ordering::AcqRel,
17932 Ordering::Acquire,
17933 )
17934 .is_ok()
17935 {
17936 return Err(failure(
17937 NodeFailureCode::RepositoryEntryCreateTimedOut,
17938 timeout_message,
17939 ));
17940 }
17941 debug_assert_eq!(
17942 commit_state.load(Ordering::Acquire),
17943 WORKSPACE_ENTRY_CREATE_COMMITTING,
17944 );
17945 task.await
17946 }
17947 };
17948 joined
17949 .map_err(|_| {
17950 failure(
17951 NodeFailureCode::RepositoryEntryCreateFailed,
17952 task_failure_message,
17953 )
17954 })?
17955 .map_err(workspace_entry_create_failure)
17956}
17957
17958fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17959 let code = match error.kind() {
17960 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17961 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17962 NodeFailureCode::RepositoryPathUnsafe
17963 }
17964 WorkspaceFileReadErrorKind::AlreadyExists => {
17965 NodeFailureCode::RepositoryEntryAlreadyExists
17966 }
17967 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17968 NodeFailureCode::RepositoryParentNotFound
17969 }
17970 WorkspaceFileReadErrorKind::ParentNotDirectory
17971 | WorkspaceFileReadErrorKind::NotRegularFile => {
17972 NodeFailureCode::RepositoryParentNotDirectory
17973 }
17974 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17975 NodeFailureCode::RepositoryEntryCreateFailed
17976 }
17977 WorkspaceFileReadErrorKind::RevisionConflict => {
17978 NodeFailureCode::RepositoryEntryCreateFailed
17979 }
17980 WorkspaceFileReadErrorKind::Canceled => {
17981 NodeFailureCode::RepositoryEntryCreateTimedOut
17982 }
17983 };
17984 failure(code, "repository entry create failed")
17985}
17986
17987fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17988 let value = digest(&SHA256, bytes)
17989 .as_ref()
17990 .iter()
17991 .map(|byte| format!("{byte:02x}"))
17992 .collect();
17993 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17994}
17995
17996fn managed_spawn_request_digest_v2(
17997 request: &ManagedWorktreeSpawnRequestV2,
17998) -> Result<String, NodeFailure> {
17999 let encoded = serde_json::to_vec(request).map_err(|_| {
18000 failure(
18001 NodeFailureCode::InvalidRequest,
18002 "managed worktree V2 request could not be canonicalized",
18003 )
18004 })?;
18005 let mut value = String::with_capacity(71);
18006 value.push_str("sha256:");
18007 for byte in digest(&SHA256, &encoded).as_ref() {
18008 use std::fmt::Write as _;
18009 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18010 }
18011 Ok(value)
18012}
18013
18014fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18015 let code = match error {
18016 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18017 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18018 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18019 NativeSessionPreviewError::UnsupportedProvider
18020 | NativeSessionPreviewError::PreviewUnavailable
18021 | NativeSessionPreviewError::SessionNotFound
18022 | NativeSessionPreviewError::AmbiguousSession => {
18023 NodeFailureCode::BackendOperationFailed
18024 }
18025 };
18026 failure(code, "native session preview failed")
18027}
18028
18029fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18030 let code = match error.kind() {
18031 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18032 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18033 };
18034 failure(code, "host directory browse failed")
18035}
18036
18037fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18038 let code = match error {
18039 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18040 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18041 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18042 NodeFailureCode::DeliveryStageConflict
18043 }
18044 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18045 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18046 NodeFailureCode::DeliveryChunkOutOfOrder
18047 }
18048 DeliveryStoreError::BlobDigestMismatch => {
18049 NodeFailureCode::DeliveryBlobDigestMismatch
18050 }
18051 DeliveryStoreError::BundleDigestMismatch => {
18052 NodeFailureCode::DeliveryBundleDigestMismatch
18053 }
18054 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18055 DeliveryStoreError::Unavailable
18056 | DeliveryStoreError::Corrupt
18057 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18058 };
18059 failure(code, "delivery operation failed")
18060}
18061
18062fn validate_workspace_root(
18063 workspace_id: &WorkspaceId,
18064 canonical_root: &str,
18065) -> Result<(), NodeServerError> {
18066 #[cfg(windows)]
18067 if canonical_root.starts_with(r"\\") {
18068 return Err(NodeServerError::InvalidWorkspaceRoot {
18069 workspace_id: workspace_id.clone(),
18070 path: canonical_root.to_owned(),
18071 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18072 });
18073 }
18074 if !platform::workspace_root_supported(canonical_root)
18075 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18076 {
18077 return Err(NodeServerError::InvalidWorkspaceRoot {
18078 workspace_id: workspace_id.clone(),
18079 path: canonical_root.to_owned(),
18080 message: "canonical path must be absolute, bounded, and free of control characters"
18081 .to_owned(),
18082 });
18083 }
18084 Ok(())
18085}
18086
18087fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18088 NodeFailure {
18089 code,
18090 message: node_failure_category(code).to_owned(),
18091 }
18092}
18093
18094fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18095 let code = match error {
18096 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18097 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18098 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18099 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18100 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18101 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18102 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18103 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18104 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18105 };
18106 failure(code, "harness MCP proxy operation failed")
18107}
18108
18109static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18113
18114fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18121 let trace = launch.trace.as_ref()?;
18122 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18123 if trace_dir.is_empty() {
18124 return None;
18125 }
18126 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18127 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18128 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18129}
18130
18131fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18132 let remaining = deadline.saturating_duration_since(Instant::now());
18133 if remaining.is_zero() {
18134 Err(failure(
18135 NodeFailureCode::SpawnDeadlineExceeded,
18136 "spawn deadline elapsed",
18137 ))
18138 } else {
18139 Ok(remaining)
18140 }
18141}
18142
18143fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18144 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18145 match deadline {
18146 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18147 None => Ok(bounded),
18148 }
18149}
18150
18151fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18152 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18153 failure(
18154 NodeFailureCode::SpawnDeadlineExceeded,
18155 "spawn dispatch exceeded the spawn deadline",
18156 )
18157 } else {
18158 error
18159 }
18160}
18161
18162fn spawn_runtime_capabilities(
18163 capabilities: &SpawnRequiredCapabilities,
18164) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18165 capabilities
18166 .iter()
18167 .map(|capability| match capability.as_str() {
18168 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18169 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18170 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18171 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18172 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18173 }
18174 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18175 _ => Err(failure(
18176 NodeFailureCode::UnsupportedSpawnCapability,
18177 "spawn required capability is unknown",
18178 )),
18179 })
18180 .collect()
18181}
18182
18183fn persistence_failure(_error: io::Error) -> NodeFailure {
18184 NodeFailure {
18185 code: NodeFailureCode::BackendOperationFailed,
18186 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18187 }
18188}
18189
18190fn node_failure_category(code: NodeFailureCode) -> &'static str {
18191 match code {
18192 NodeFailureCode::InvalidRequest => "invalid-request",
18193 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18194 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18195 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18196 NodeFailureCode::ReservationConflict => "reservation-conflict",
18197 NodeFailureCode::ReservationExpired => "reservation-expired",
18198 NodeFailureCode::BindingMismatch => "binding-mismatch",
18199 NodeFailureCode::NotActivated => "not-activated",
18200 NodeFailureCode::CallNotFound => "call-not-found",
18201 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18202 NodeFailureCode::ResponseTooLarge => "response-too-large",
18203 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18204 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18205 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18206 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18207 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18208 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18209 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18210 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18211 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18212 NodeFailureCode::Unauthorized => "unauthorized",
18213 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18214 NodeFailureCode::ControllerBusy => "controller-busy",
18215 NodeFailureCode::ControllerRequired => "controller-required",
18216 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18217 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18218 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18219 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18220 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18221 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18222 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18223 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18224 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18225 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18226 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18227 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18228 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18229 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18230 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18231 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18232 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18233 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18234 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18235 NodeFailureCode::GitReadFailed => "git-read-failed",
18236 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18237 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18238 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18239 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18240 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18241 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18242 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18243 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18244 NodeFailureCode::TurnInFlight => "turn-in-flight",
18245 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18246 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18247 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18248 "unsupported-network-allowlist-mapping"
18249 }
18250 NodeFailureCode::BrowserStationProbeUnavailable => {
18251 "browser-station-probe-unavailable"
18252 }
18253 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18254 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18255 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18256 "environment-profile-binding-mismatch"
18257 }
18258 NodeFailureCode::UnknownBundle => "unknown-bundle",
18259 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18260 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18261 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18262 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18263 NodeFailureCode::ContextPackMaterializationFailed => {
18264 "context-pack-materialization-failed"
18265 }
18266 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18267 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18268 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18269 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18270 NodeFailureCode::LastWorkspace => "last-workspace",
18271 NodeFailureCode::NotGitRepository => "not-git-repository",
18272 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18273 NodeFailureCode::WorktreeProtected => "worktree-protected",
18274 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18275 NodeFailureCode::WorktreeLocked => "worktree-locked",
18276 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18277 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18278 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18279 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18280 "managed-worktree-profile-revision-mismatch"
18281 }
18282 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18283 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18284 "standalone-workspace-recovery-required"
18285 }
18286 NodeFailureCode::UnknownSession => "unknown-session",
18287 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18288 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18289 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18290 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18291 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18292 NodeFailureCode::WorkspaceRegistrationRequired => {
18293 "workspace-registration-required"
18294 }
18295 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18296 NodeFailureCode::StaleGeneration => "stale-generation",
18297 NodeFailureCode::BackendBusy => "backend-busy",
18298 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18299 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18300 NodeFailureCode::ShuttingDown => "shutting-down",
18301 }
18302}
18303
18304fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18305 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18306}
18307
18308fn durable_state_load_error(error: io::Error) -> NodeServerError {
18309 let category = match session_registry::state_load_refusal(&error) {
18310 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18311 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18312 }
18313 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18314 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18315 }
18316 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18317 DURABLE_STATE_CONFLICT_ERROR
18318 }
18319 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18320 };
18321 durable_state_server_error(error, category)
18322}
18323
18324fn unix_time_ms() -> u64 {
18325 std::time::SystemTime::now()
18326 .duration_since(std::time::UNIX_EPOCH)
18327 .unwrap_or_default()
18328 .as_millis()
18329 .min(u64::MAX as u128) as u64
18330}
18331
18332fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18333 if record.provider_session.is_some() {
18334 ManagedSessionState::Dormant
18335 } else {
18336 ManagedSessionState::Unavailable
18337 }
18338}
18339
18340fn active_record_state(
18341 identity_admitted: bool,
18342 provider_session_is_known: bool,
18343) -> ManagedSessionState {
18344 if identity_admitted && !provider_session_is_known {
18345 ManagedSessionState::IdentityPending
18346 } else {
18347 ManagedSessionState::Live
18348 }
18349}
18350
18351fn managed_worktree_record_holders(
18352 records: &[ManagedSessionRecord],
18353) -> Vec<(SessionRecordId, WorkspaceId)> {
18354 records
18355 .iter()
18356 .filter(|record| record.provider_session.is_some())
18357 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18358 .collect()
18359}
18360
18361fn managed_spawn_record_ownership(
18362 binding: &SessionBinding,
18363 effective_runtime_policy: ProviderRuntimePolicy,
18364) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18365 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18366 if effective_runtime_policy.provider_session_identity {
18367 (binding.record_id.clone(), None)
18368 } else {
18369 (None, binding.record_id.clone())
18370 }
18371}
18372
18373#[derive(Debug, Error)]
18374pub enum NodeServerError {
18375 #[error("node endpoint must be a bounded absolute local transport path")]
18376 InvalidEndpoint,
18377 #[error("node access token must contain 1..=4096 bytes")]
18378 InvalidAccessToken,
18379 #[error("node HTTP observer must listen on a loopback address: {0}")]
18380 InvalidApiListen(std::net::SocketAddr),
18381 #[error("node browser bridge must listen on a loopback address: {0}")]
18382 InvalidBridgeListen(std::net::SocketAddr),
18383 #[error("node browser bridge token must contain 1..=4096 bytes")]
18384 InvalidBridgeToken,
18385 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18386 BridgeUnderlayRequiresBridgeListen,
18387 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18388 BridgeUnderlayRequiresBridgeToken,
18389 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18390 InvalidBridgeUnderlayToken,
18391 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18392 InvalidBridgeUnderlayKey,
18393 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18394 InvalidCallHome(std::net::SocketAddr),
18395 #[error("node could not reach its call-home relay: {0}")]
18396 CallHomeTransport(#[source] std::io::Error),
18397 #[error("node durable state path must be an absolute file path: {0}")]
18398 InvalidStatePath(String),
18399 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18400 InvalidHarnessMcpHelper,
18401 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18402 InvalidNetworkAllowlistCatalogPath(String),
18403 #[error("network allowlist catalog could not be read: {0}")]
18404 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18405 #[error("network allowlist catalog line {line}: {message}")]
18406 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18407 #[error("network allowlist catalog: {0}")]
18408 InvalidNetworkAllowlistCatalog(String),
18409 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18410 NetworkAllowlistCatalogCapacity { max: usize },
18411 #[error("the local state directory is unavailable; supply an explicit state path")]
18412 LocalStateDirectoryUnavailable,
18413 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18414 LocalRuntimeDirectoryUnavailable,
18415 #[error("node durable state failed: {0}")]
18416 DurableState(io::Error),
18417 #[error("node requires at least one configured workspace")]
18418 NoWorkspaces,
18419 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18420 InvalidWorkspaceRoot {
18421 workspace_id: WorkspaceId,
18422 path: String,
18423 message: String,
18424 },
18425 #[error("duplicate workspace ID: {0}")]
18426 DuplicateWorkspaceId(WorkspaceId),
18427 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18428 DuplicateWorkspaceRoot {
18429 first: WorkspaceId,
18430 second: WorkspaceId,
18431 root: String,
18432 },
18433 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18434 ManagedWorktreeProfileRequired(WorkspaceId),
18435 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18436 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18437 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18438 DuplicateManagedWorktreeProfile {
18439 workspace_id: WorkspaceId,
18440 profile_id: WorktreeProfileId,
18441 },
18442 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18443 ManagedWorktreeProfileCapacity {
18444 workspace_id: WorkspaceId,
18445 max: usize,
18446 },
18447 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18448 InvalidManagedWorktreeProfile {
18449 workspace_id: WorkspaceId,
18450 profile_id: WorktreeProfileId,
18451 message: String,
18452 },
18453 #[error("duplicate node environment profile: {0}")]
18454 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18455 #[error("node environment profile provider is unavailable: {0}")]
18456 EnvironmentProfileProviderUnavailable(AgentId),
18457 #[error("node environment profile capacity is {max}")]
18458 EnvironmentProfileCapacity { max: usize },
18459 #[error("node environment profiles reuse native profile ID '{0}'")]
18460 DuplicateNativeEnvironmentProfile(String),
18461 #[error("node native environment profile is invalid: {0}")]
18462 NativeEnvironmentProfile(String),
18463 #[error("node session-environment materialization root must be absolute")]
18464 InvalidSessionEnvironmentRoot,
18465 #[error("node session-environment materializer is unavailable")]
18466 SessionEnvironmentMaterializerRequired,
18467 #[error("node session-environment materializer failed to initialize")]
18468 SessionEnvironmentMaterializer,
18469 #[error("node bundle catalog is invalid: {0}")]
18470 BundleCatalog(String),
18471 #[error("node delivery store failed to initialize: {0}")]
18472 DeliveryStore(String),
18473 #[error("node context pack store failed to initialize")]
18474 ContextPackStore,
18475 #[error("active agent registry failed: {0}")]
18476 Registry(String),
18477 #[error("node provider contract manifest is invalid: {0}")]
18478 ProviderContractManifest(String),
18479 #[error("named pipe I/O failed: {0}")]
18480 Io(#[from] io::Error),
18481 #[error("node HTTP observer failed: {0}")]
18482 HttpApi(io::Error),
18483 #[error("node browser bridge failed: {0}")]
18484 BridgeApi(io::Error),
18485 #[error(transparent)]
18486 Frame(#[from] FrameError),
18487 #[error("node handshake failed: {0}")]
18488 Handshake(String),
18489 #[error("node authentication frame was not received before the bounded deadline")]
18490 AuthenticationTimedOut,
18491 #[error("node authentication primitive failed: {0}")]
18492 Authentication(String),
18493 #[error("node incarnation identity generation failed: {0}")]
18494 IncarnationIdentity(String),
18495 #[error("node pre-authentication limiter is closed")]
18496 PreauthClosed,
18497 #[error("node authenticated connection limit was reached")]
18498 AuthenticatedConnectionLimit,
18499 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18500 SignalStreamClosed,
18501 #[error("node stop-all dispatch failed: {0:?}")]
18502 ShutdownDispatch(NodeFailure),
18503 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18504 ShutdownTimedOut { active_native_sessions: usize },
18505}
18506
18507impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18508 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18509 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18510 match error {
18511 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18512 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18513 E::InvalidEntry { line, message } => {
18514 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18515 }
18516 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18517 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18518 }
18519 }
18520}
18521
18522#[cfg(test)]
18523mod agent_stream_blocked_tests {
18524 use super::*;
18525 use gate4agent_types::{
18526 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18527 ProviderSource,
18528 };
18529
18530 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18531 ProviderSource {
18532 family,
18533 binding: AdapterBinding::new(
18534 AdapterId::new(adapter).unwrap(),
18535 "fixture/v1",
18536 AdapterVerification::SyntheticFixture,
18537 )
18538 .unwrap(),
18539 }
18540 }
18541
18542 fn provider_control_event_at(
18543 family: AdapterFamily,
18544 adapter: &str,
18545 source_sequence: u64,
18546 provider_event: ProviderEvent,
18547 ) -> ControlEvent {
18548 ControlEvent {
18549 sequence: 41,
18550 command_id: None,
18551 instance_id: AgentInstanceId(7),
18552 generation: SessionGeneration(3),
18553 event: ControlEventKind::ProviderEvent {
18554 sequence: 8,
18555 source: provider_source(family, adapter),
18556 source_sequence,
18557 event: provider_event,
18558 },
18559 }
18560 }
18561
18562 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18563 provider_control_event_at(family, "fixture", 9, provider_event)
18564 }
18565
18566 fn denied_host_request(
18567 by: ProviderHostDecisionAuthority,
18568 reason: Option<String>,
18569 ) -> ControlEvent {
18570 provider_control_event_at(
18571 AdapterFamily::Acp,
18572 "claude-code",
18573 2,
18574 ProviderEvent::HostRequestObserved {
18575 method: "terminal/create".to_owned(),
18576 params_json: String::new(),
18577 decision: ProviderHostRequestDecision::Denied { by },
18578 outcome: ProviderHostRequestOutcome::Executed,
18579 reason,
18580 },
18581 )
18582 }
18583
18584 fn blocked(
18585 event: &ControlEvent,
18586 ) -> (
18587 Option<String>,
18588 String,
18589 BlockAuthorityV1,
18590 Option<String>,
18591 String,
18592 Option<String>,
18593 ) {
18594 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18595 chunk.validate().expect("minted chunk must validate");
18596 let AgentStreamChunkKindV1::Blocked {
18597 correlation_id,
18598 tool_class,
18599 authority,
18600 reason_kind,
18601 reason,
18602 help,
18603 } = chunk.kind
18604 else {
18605 panic!("expected AgentStreamChunkKindV1::Blocked");
18606 };
18607 (correlation_id, tool_class, authority, reason_kind, reason, help)
18608 }
18609
18610 #[test]
18614 fn the_enabled_provider_matrix_is_exact() {
18615 let registry = active_registry().unwrap();
18616 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18617 assert_eq!(
18618 providers
18619 .iter()
18620 .map(|contract| contract.provider.as_str())
18621 .collect::<Vec<_>>(),
18622 vec!["shell", "claude", "codex", "grok", "kimi"],
18624 );
18625 assert!(adapters.iter().all(|contract| {
18626 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18627 }));
18628 }
18629
18630 #[test]
18633 fn gate_denied_host_request_mints_a_blocked_chunk() {
18634 let gate_text =
18635 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18636 let event = denied_host_request(
18637 ProviderHostDecisionAuthority::Gate,
18638 Some(gate_text.to_owned()),
18639 );
18640 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18641 assert_eq!(chunk.source_sequence, 2);
18642 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18643 assert_eq!(authority, BlockAuthorityV1::HostGate);
18644 assert_eq!(reason, gate_text);
18645 assert_eq!(correlation_id, None);
18646 assert_eq!(reason_kind, None);
18647 assert_eq!(help, None);
18648 assert_eq!(tool_class, "Write");
18649 }
18650
18651 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.";
18652
18653 #[test]
18659 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18660 let event = provider_control_event(
18661 AdapterFamily::Acp,
18662 ProviderEvent::ToolCompleted {
18663 id: "toolu_9".to_owned(),
18664 output: CLASSIFIER_SAMPLE.to_owned(),
18665 is_error: true,
18666 duration_ms: None,
18667 agent_id: None,
18668 non_execution_kind: Some("permission-rule".to_owned()),
18669 },
18670 );
18671 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18672 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18673 assert_eq!(reason, "Blocked by classifier");
18674 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18675 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18676 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18677 }
18678
18679 #[test]
18684 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18685 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.";
18686 let event = provider_control_event(
18687 AdapterFamily::Acp,
18688 ProviderEvent::SessionEnded {
18689 result: "JSON-RPC error -32603: Internal error".to_owned(),
18690 cost_usd: None,
18691 is_error: true,
18692 stop_reason: Some(ProviderStopReason::ProviderError {
18693 code: -32603,
18694 message: message.to_owned(),
18695 vendor_code: Some("usageLimitExceeded".to_owned()),
18696 }),
18697 },
18698 );
18699 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18700 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18701 assert_eq!(reason, message);
18702 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18703 assert_eq!(tool_class, "Session");
18704 }
18705
18706 #[test]
18709 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18710 let event = provider_control_event(
18711 AdapterFamily::Acp,
18712 ProviderEvent::SessionEnded {
18713 result: "refusal".to_owned(),
18714 cost_usd: None,
18715 is_error: true,
18716 stop_reason: Some(ProviderStopReason::Refusal),
18717 },
18718 );
18719 let (_, _, authority, _, _, _) = blocked(&event);
18720 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18721 }
18722
18723 #[test]
18727 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18728 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18729 let event = provider_control_event(
18730 AdapterFamily::Acp,
18731 ProviderEvent::ToolCompleted {
18732 id: "toolu_1".to_owned(),
18733 output: "command exited with status 1".to_owned(),
18734 is_error: true,
18735 duration_ms: Some(12),
18736 agent_id: None,
18737 non_execution_kind,
18738 },
18739 );
18740 assert!(agent_stream_chunk(&event).is_none());
18741 }
18742 let narrative = provider_control_event(
18743 AdapterFamily::Acp,
18744 ProviderEvent::ToolCompleted {
18745 id: "toolu_2".to_owned(),
18746 output: "the harness blocked the write, approval needed".to_owned(),
18747 is_error: false,
18748 duration_ms: Some(3),
18749 agent_id: None,
18750 non_execution_kind: None,
18751 },
18752 );
18753 assert!(agent_stream_chunk(&narrative).is_none());
18754 }
18755
18756 #[test]
18760 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18761 for (decision, outcome) in [
18762 (
18763 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18764 ProviderHostRequestOutcome::Executed,
18765 ),
18766 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18767 (
18768 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18769 ProviderHostRequestOutcome::Failed {
18770 error: "terminal/create spawn failed: os error 3".to_owned(),
18771 },
18772 ),
18773 ] {
18774 let event = provider_control_event_at(
18775 AdapterFamily::Acp,
18776 "claude-code",
18777 3,
18778 ProviderEvent::HostRequestObserved {
18779 method: "fs/read_text_file".to_owned(),
18780 params_json: String::new(),
18781 decision: decision.clone(),
18782 outcome,
18783 reason: None,
18784 },
18785 );
18786 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18787 }
18788 }
18789
18790 #[test]
18794 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18795 for (by, expected_authority, expected_reason) in [
18796 (
18797 ProviderHostDecisionAuthority::Operator,
18798 BlockAuthorityV1::Operator,
18799 "declined by the operator",
18800 ),
18801 (
18802 ProviderHostDecisionAuthority::DeadlinePolicy,
18803 BlockAuthorityV1::HostDeadline,
18804 "denied by host policy after the operator deadline expired",
18805 ),
18806 ] {
18807 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18808 assert_eq!(authority, expected_authority);
18809 assert_eq!(reason, expected_reason);
18810 }
18811 }
18812
18813 #[test]
18817 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18818 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18819 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18820 ProviderHostDecisionAuthority::Policy,
18821 Some(oversized),
18822 ));
18823 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18824 }
18825}
18826
18827#[cfg(test)]
18828mod standalone_capability_platform_tests {
18829 use super::*;
18830
18831 #[test]
18832 fn standalone_workspace_capability_requires_windows_identity_guards() {
18833 let capabilities = baseline_capabilities().unwrap();
18834 let advertised = capabilities.iter().any(|capability| {
18835 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18836 });
18837 assert_eq!(advertised, cfg!(windows));
18838
18839 let request = NodeRequest::CreateStandaloneWorkspace {
18840 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18841 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18842 initial_branch: None,
18843 };
18844 assert_eq!(
18845 request_uses_unnegotiated_capability(&request, &capabilities),
18846 !cfg!(windows),
18847 );
18848 }
18849}
18850
18851
18852#[cfg(test)]
18853mod station_knobs_resolve_tests {
18854 use super::*;
18855 use gate4agent_catalog::EnvMutation;
18856 use gate4agent_runtime_native::{
18857 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18858 NativeLaunchProfileId,
18859 };
18860 use std::ffi::OsString;
18861 use std::sync::Arc;
18862
18863 struct EmptyEnvironmentResolver;
18864
18865 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18866 fn resolve_child_environment(
18867 &self,
18868 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18869 Ok(vec![EnvMutation {
18870 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18871 value: None,
18872 }])
18873 }
18874 }
18875
18876 fn agent(value: &str) -> AgentId {
18877 AgentId::new(value).unwrap()
18878 }
18879
18880 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18881 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18882 let profile_revision =
18883 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18884 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18885 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18886 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18887 provider: agent("claude"),
18888 mode: SessionMode::Pty,
18889 terminal_size: gate4agent_types::TerminalSize {
18890 rows: 24,
18891 columns: 80,
18892 },
18893 prompt: None,
18894 bundle_id: None,
18895 context_id: None,
18896 environment_profile_id: Some(profile_id.clone()),
18897 };
18898 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18899 let catalog = active_registry().unwrap();
18900 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18901 let control = runtime.native_launch_profile_control();
18902 let node_profile = NodeEnvironmentProfile::new(
18903 profile_id.clone(),
18904 profile_revision,
18905 agent("claude"),
18906 [NativeLaunchProfile::new(
18907 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18908 agent("claude"),
18909 TransportKind::Pty,
18910 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18911 Arc::new(EmptyEnvironmentResolver),
18912 )
18913 .unwrap()],
18914 )
18915 .unwrap();
18916 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18917 for native_profile in native_profiles {
18918 runtime.upsert_native_launch_profile(native_profile).unwrap();
18919 }
18920 let workspace = WorkspaceConfig::new(
18921 WorkspaceId::new("primary").unwrap(),
18922 std::env::current_dir().unwrap(),
18923 )
18924 .unwrap();
18925 let shared = NodeShared::new_with_incarnation(
18926 handle,
18927 "fixture-token".to_owned(),
18928 NodeId::new("node-station-knobs-test").unwrap(),
18929 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18930 vec![workspace],
18931 vec![agent("claude")],
18932 ProviderRuntimeStatuses::default(),
18933 None,
18934 Vec::new(),
18935 Vec::new(),
18936 spawn_profiles,
18937 Some(control),
18938 None,
18939 None,
18940 None,
18941 Vec::new(),
18942 Vec::new(),
18943 Vec::new(),
18944 Vec::new(),
18945 None,
18946 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18947 );
18948 shared
18949 .environment_profiles
18950 .write()
18951 .unwrap_or_else(|poisoned| poisoned.into_inner())
18952 .insert(binding.id.clone(), binding);
18953 (shared, spawn_defaults)
18954 }
18955
18956 #[test]
18959 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18960 let (shared, defaults) = station_knobs_fixture();
18961 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18962 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18963 let spec = crate::protocol::SpawnSpec {
18964 target: crate::protocol::SpawnTarget {
18965 node_id: shared.node_id.clone(),
18966 workspace_id: WorkspaceId::new("primary").unwrap(),
18967 worktree_id: None,
18968 },
18969 profile_id: defaults.profile_id.clone(),
18970 expected_profile_revision: defaults.revision.clone(),
18971 overrides: crate::protocol::SpawnOverrides {
18972 network_allowlist: Some(unknown),
18973 browser_profile_id: Some(browser),
18974 ..crate::protocol::SpawnOverrides::default()
18975 },
18976 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18977 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18978 "station-knobs-unknown-allowlist",
18979 )
18980 .unwrap(),
18981 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18982 };
18983 let resolved = spec.resolve(&defaults).unwrap();
18984 let failure = shared
18985 .resolve_environment_profile(&resolved)
18986 .expect_err("unknown allowlist must refuse");
18987 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18988 }
18989
18990 #[test]
18996 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18997 let (shared, defaults) = station_knobs_fixture();
18998 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18999 #[cfg(not(feature = "dig2-station-probe"))]
19000 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19001 shared
19002 .network_allowlist_catalog
19003 .write()
19004 .unwrap_or_else(|poisoned| poisoned.into_inner())
19005 .insert_id(allowlist.clone())
19006 .unwrap();
19007 let spec = crate::protocol::SpawnSpec {
19008 target: crate::protocol::SpawnTarget {
19009 node_id: shared.node_id.clone(),
19010 workspace_id: WorkspaceId::new("primary").unwrap(),
19011 worktree_id: None,
19012 },
19013 profile_id: defaults.profile_id.clone(),
19014 expected_profile_revision: defaults.revision.clone(),
19015 overrides: crate::protocol::SpawnOverrides {
19016 network_allowlist: Some(allowlist.clone()),
19017 #[cfg(not(feature = "dig2-station-probe"))]
19018 browser_profile_id: Some(browser.clone()),
19019 ..crate::protocol::SpawnOverrides::default()
19020 },
19021 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19022 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19023 .unwrap(),
19024 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19025 };
19026 let resolved = spec.resolve(&defaults).unwrap();
19027 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19028 #[cfg(not(feature = "dig2-station-probe"))]
19029 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19030 let receipt = shared
19031 .resolve_environment_profile(&resolved)
19032 .expect("resolve ok")
19033 .expect("env profile present");
19034 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19035 #[cfg(not(feature = "dig2-station-probe"))]
19036 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19037 #[cfg(feature = "dig2-station-probe")]
19038 assert_eq!(receipt.browser_profile_id, None);
19039 assert_eq!(receipt.profile_id.as_str(), "local-claude");
19040
19041 let omitted = crate::protocol::SpawnSpec {
19042 overrides: crate::protocol::SpawnOverrides::default(),
19043 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19044 .unwrap(),
19045 ..spec
19046 };
19047 let resolved_omit = omitted.resolve(&defaults).unwrap();
19048 let bare = shared
19049 .resolve_environment_profile(&resolved_omit)
19050 .expect("resolve ok")
19051 .expect("env profile present");
19052 assert_eq!(bare.network_allowlist, None);
19053 assert_eq!(bare.browser_profile_id, None);
19054 }
19055
19056 #[cfg(feature = "dig2-station-probe")]
19061 #[test]
19062 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19063 let (shared, defaults) = station_knobs_fixture();
19064 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19065 let spec = crate::protocol::SpawnSpec {
19066 target: crate::protocol::SpawnTarget {
19067 node_id: shared.node_id.clone(),
19068 workspace_id: WorkspaceId::new("primary").unwrap(),
19069 worktree_id: None,
19070 },
19071 profile_id: defaults.profile_id.clone(),
19072 expected_profile_revision: defaults.revision.clone(),
19073 overrides: crate::protocol::SpawnOverrides {
19074 browser_profile_id: Some(browser),
19075 ..crate::protocol::SpawnOverrides::default()
19076 },
19077 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19078 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19079 "station-knobs-probe-refuse",
19080 )
19081 .unwrap(),
19082 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19083 };
19084 let resolved = spec.resolve(&defaults).unwrap();
19085 let failure = shared
19086 .resolve_environment_profile(&resolved)
19087 .expect_err("browser_profile with failing probe must refuse");
19088 #[cfg(windows)]
19089 {
19090 assert!(
19091 matches!(
19092 failure.code,
19093 NodeFailureCode::BrowserStationUnreachable
19094 | NodeFailureCode::BrowserStationProbeUnavailable
19095 ),
19096 "unexpected code {:?}",
19097 failure.code
19098 );
19099 }
19100 #[cfg(not(windows))]
19101 {
19102 assert_eq!(
19103 failure.code,
19104 NodeFailureCode::BrowserStationProbeUnavailable
19105 );
19106 }
19107 }
19108
19109 #[cfg(feature = "dig2-station-probe")]
19111 #[test]
19112 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19113 let (shared, defaults) = station_knobs_fixture();
19114 let spec = crate::protocol::SpawnSpec {
19115 target: crate::protocol::SpawnTarget {
19116 node_id: shared.node_id.clone(),
19117 workspace_id: WorkspaceId::new("primary").unwrap(),
19118 worktree_id: None,
19119 },
19120 profile_id: defaults.profile_id.clone(),
19121 expected_profile_revision: defaults.revision.clone(),
19122 overrides: crate::protocol::SpawnOverrides::default(),
19123 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19124 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19125 "station-knobs-probe-omit",
19126 )
19127 .unwrap(),
19128 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19129 };
19130 let resolved = spec.resolve(&defaults).unwrap();
19131 let receipt = shared
19132 .resolve_environment_profile(&resolved)
19133 .expect("omit browser_profile must not probe")
19134 .expect("env profile present");
19135 assert_eq!(receipt.browser_profile_id, None);
19136 }
19137
19138 #[cfg(feature = "dig2-station-probe")]
19142 #[test]
19143 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19144 let (shared, _defaults) = station_knobs_fixture();
19145 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19146 let workspace_id = WorkspaceId::new("primary").unwrap();
19147 let address_a = SessionAddress {
19148 workspace_id: workspace_id.clone(),
19149 session: SessionKey {
19150 instance_id: AgentInstanceId(101),
19151 generation: SessionGeneration(1),
19152 },
19153 };
19154 let address_b = SessionAddress {
19155 workspace_id: workspace_id.clone(),
19156 session: SessionKey {
19157 instance_id: AgentInstanceId(102),
19158 generation: SessionGeneration(1),
19159 },
19160 };
19161 let guard_a = crate::dig2_station_lease::acquire_lease(
19162 &shared.browser_station_leases,
19163 profile.clone(),
19164 address_a.session.instance_id,
19165 1,
19166 )
19167 .expect("first lease");
19168 let busy = crate::dig2_station_lease::acquire_lease(
19169 &shared.browser_station_leases,
19170 profile.clone(),
19171 address_b.session.instance_id,
19172 2,
19173 )
19174 .expect_err("second holder must refuse");
19175 assert!(matches!(
19176 busy,
19177 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19178 ));
19179 shared.bind_session_with_policy(
19181 &address_a,
19182 ProviderRuntimePolicy::raw_pty(),
19183 None,
19184 );
19185 guard_a.retain();
19186 assert!(shared.remove_binding(&address_a).is_some());
19187 crate::dig2_station_lease::acquire_lease(
19188 &shared.browser_station_leases,
19189 profile,
19190 address_b.session.instance_id,
19191 3,
19192 )
19193 .expect("reacquire after clear")
19194 .retain();
19195 assert!(
19196 crate::dig2_station_lease::release_lease_for_holder(
19197 &shared.browser_station_leases,
19198 address_b.session.instance_id,
19199 )
19200 );
19201 }
19202
19203 #[cfg(not(feature = "dig2-station-probe"))]
19206 #[test]
19207 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19208 let (shared, defaults) = station_knobs_fixture();
19209 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19210 let spec = crate::protocol::SpawnSpec {
19211 target: crate::protocol::SpawnTarget {
19212 node_id: shared.node_id.clone(),
19213 workspace_id: WorkspaceId::new("primary").unwrap(),
19214 worktree_id: None,
19215 },
19216 profile_id: defaults.profile_id.clone(),
19217 expected_profile_revision: defaults.revision.clone(),
19218 overrides: crate::protocol::SpawnOverrides {
19219 browser_profile_id: Some(browser.clone()),
19220 ..crate::protocol::SpawnOverrides::default()
19221 },
19222 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19223 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19224 .unwrap(),
19225 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19226 };
19227 let resolved = spec.resolve(&defaults).unwrap();
19228 let receipt = shared
19229 .resolve_environment_profile(&resolved)
19230 .expect("stub resolve")
19231 .expect("env profile");
19232 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19233 }
19234
19235
19236 #[test]
19239 fn network_allowlist_catalog_loads_from_temp_file() {
19240 let unique = std::time::SystemTime::now()
19241 .duration_since(std::time::UNIX_EPOCH)
19242 .unwrap()
19243 .as_nanos();
19244 let path = std::env::temp_dir().join(format!(
19245 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19246 std::process::id(),
19247 ));
19248 std::fs::write(
19249 &path,
19250 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19251 )
19252 .unwrap();
19253 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19254 .expect("temp catalog file must load");
19255 let _ = std::fs::remove_file(&path);
19256 assert_eq!(loaded.len(), 2);
19257 let (shared, defaults) = station_knobs_fixture();
19258 {
19259 let mut catalog = shared
19260 .network_allowlist_catalog
19261 .write()
19262 .unwrap_or_else(|poisoned| poisoned.into_inner());
19263 *catalog = loaded;
19264 }
19265 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19266 let spec = crate::protocol::SpawnSpec {
19267 target: crate::protocol::SpawnTarget {
19268 node_id: shared.node_id.clone(),
19269 workspace_id: WorkspaceId::new("primary").unwrap(),
19270 worktree_id: None,
19271 },
19272 profile_id: defaults.profile_id.clone(),
19273 expected_profile_revision: defaults.revision.clone(),
19274 overrides: crate::protocol::SpawnOverrides {
19275 network_allowlist: Some(allowlist.clone()),
19276 ..crate::protocol::SpawnOverrides::default()
19277 },
19278 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19279 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19280 "station-knobs-catalog-file",
19281 )
19282 .unwrap(),
19283 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19284 };
19285 let resolved = spec.resolve(&defaults).unwrap();
19286 let receipt = shared
19287 .resolve_environment_profile(&resolved)
19288 .expect("registered via file load")
19289 .expect("env profile present");
19290 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19291 }
19292
19293 #[test]
19296 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19297 let (shared, _defaults) = station_knobs_fixture();
19298 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19299 shared
19300 .network_allowlist_catalog
19301 .write()
19302 .unwrap_or_else(|poisoned| poisoned.into_inner())
19303 .insert_id(allowlist.clone())
19304 .unwrap();
19305 let snapshot = shared.snapshot();
19306 let inventory = snapshot
19307 .launch_inventory
19308 .expect("launch inventory present");
19309 let ids = inventory
19310 .network_allowlists
19311 .expect("non-empty catalog surfaces ids");
19312 assert_eq!(ids, vec![allowlist]);
19313 }
19314
19315 #[test]
19319 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19320 let (shared, defaults) = station_knobs_fixture();
19321 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19322 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19323 id: allowlist.clone(),
19324 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19325 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19326 peer: "127.0.0.1:443".to_owned(),
19327 }],
19328 provider_native: Some(
19329 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19330 codex_network_access: Some(true),
19331 },
19332 ),
19333 };
19334 shared
19335 .network_allowlist_catalog
19336 .write()
19337 .unwrap_or_else(|poisoned| poisoned.into_inner())
19338 .insert(entry)
19339 .unwrap();
19340 let spec = crate::protocol::SpawnSpec {
19341 target: crate::protocol::SpawnTarget {
19342 node_id: shared.node_id.clone(),
19343 workspace_id: WorkspaceId::new("primary").unwrap(),
19344 worktree_id: None,
19345 },
19346 profile_id: defaults.profile_id.clone(),
19347 expected_profile_revision: defaults.revision.clone(),
19348 overrides: crate::protocol::SpawnOverrides {
19349 network_allowlist: Some(allowlist),
19350 ..crate::protocol::SpawnOverrides::default()
19351 },
19352 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19353 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19354 "station-knobs-unsupported-mapping",
19355 )
19356 .unwrap(),
19357 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19358 };
19359 let resolved = spec.resolve(&defaults).unwrap();
19360 assert_eq!(resolved.provider.as_str(), "claude");
19361 let failure = shared
19362 .resolve_environment_profile(&resolved)
19363 .expect_err("claude + codex_network_access must refuse");
19364 assert_eq!(
19365 failure.code,
19366 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19367 );
19368 }
19369
19370 #[test]
19374 fn resolve_codex_network_access_overlay_moderate_only() {
19375 let (shared, defaults) = station_knobs_fixture();
19376 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19377 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19378 id: allowlist.clone(),
19379 permits: Vec::new(),
19380 provider_native: Some(
19381 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19382 codex_network_access: Some(true),
19383 },
19384 ),
19385 };
19386 shared
19387 .network_allowlist_catalog
19388 .write()
19389 .unwrap_or_else(|poisoned| poisoned.into_inner())
19390 .insert(entry)
19391 .unwrap();
19392
19393 let overlay = shared
19394 .resolve_codex_network_access_overlay_args(
19395 &agent("codex"),
19396 SessionMode::Pty,
19397 ApprovalLevel::Moderate,
19398 Some(&allowlist),
19399 )
19400 .expect("Moderate Codex maps network_access");
19401 assert_eq!(
19402 overlay,
19403 vec![
19404 std::ffi::OsString::from("-c"),
19405 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19406 ]
19407 );
19408
19409 let acp = shared
19410 .resolve_codex_network_access_overlay_args(
19411 &agent("codex"),
19412 SessionMode::Acp,
19413 ApprovalLevel::Moderate,
19414 Some(&allowlist),
19415 )
19416 .unwrap();
19417 assert_eq!(acp, overlay);
19418
19419 let inline = shared
19420 .resolve_codex_network_access_overlay_args(
19421 &agent("codex"),
19422 SessionMode::Inline,
19423 ApprovalLevel::Moderate,
19424 Some(&allowlist),
19425 )
19426 .unwrap_err();
19427 assert_eq!(
19428 inline.code,
19429 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19430 );
19431
19432 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19433 let spec = crate::protocol::SpawnSpec {
19434 target: crate::protocol::SpawnTarget {
19435 node_id: shared.node_id.clone(),
19436 workspace_id: WorkspaceId::new("primary").unwrap(),
19437 worktree_id: None,
19438 },
19439 profile_id: defaults.profile_id.clone(),
19440 expected_profile_revision: defaults.revision.clone(),
19441 overrides: crate::protocol::SpawnOverrides {
19442 provider: crate::protocol::SpawnOverride::Set {
19443 value: agent("codex"),
19444 },
19445 approval_level: Some(level),
19446 network_allowlist: Some(allowlist.clone()),
19447 ..crate::protocol::SpawnOverrides::default()
19448 },
19449 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19450 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19451 "codex-net-refuse-{}",
19452 match level {
19453 ApprovalLevel::ReadOnly => "ro",
19454 ApprovalLevel::FullAuto => "fa",
19455 _ => "other",
19456 }
19457 ))
19458 .unwrap(),
19459 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19460 };
19461 let resolved = spec.resolve(&defaults).unwrap();
19462 let failure = shared
19463 .resolve_environment_profile(&resolved)
19464 .expect_err("RO/FullAuto must refuse");
19465 assert_eq!(
19466 failure.code,
19467 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19468 );
19469 }
19470 }
19471
19472 #[test]
19476 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19477 let (shared, _defaults) = station_knobs_fixture();
19478 let allowlist =
19479 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19480 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19481 id: allowlist.clone(),
19482 permits: vec![
19483 crate::network_allowlist_catalog::NetworkPermitSketch {
19484 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19485 peer: "github.com:443".to_owned(),
19486 },
19487 crate::network_allowlist_catalog::NetworkPermitSketch {
19488 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19489 peer: "*.npmjs.org".to_owned(),
19490 },
19491 ],
19492 provider_native: None,
19493 };
19494 shared
19495 .network_allowlist_catalog
19496 .write()
19497 .unwrap_or_else(|poisoned| poisoned.into_inner())
19498 .insert(entry)
19499 .unwrap();
19500
19501 let unique = std::time::SystemTime::now()
19502 .duration_since(std::time::UNIX_EPOCH)
19503 .unwrap()
19504 .as_nanos();
19505 let provider_home = std::env::temp_dir().join(format!(
19506 "gate4agent-claude-provider-home-{}-{unique}",
19507 std::process::id(),
19508 ));
19509 std::fs::create_dir_all(&provider_home).unwrap();
19510
19511 let overlay = shared
19512 .resolve_network_allowlist_overlay_args(
19513 &agent("claude"),
19514 SessionMode::Pty,
19515 ApprovalLevel::Moderate,
19516 Some(&allowlist),
19517 Some(provider_home.as_path()),
19518 )
19519 .expect("Claude PTY settings overlay on unix");
19520 let settings_path = provider_home.join(
19521 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19522 );
19523 assert_eq!(
19524 overlay,
19525 vec![
19526 std::ffi::OsString::from("--settings"),
19527 std::ffi::OsString::from(settings_path.as_os_str()),
19528 ]
19529 );
19530 let body = std::fs::read_to_string(&settings_path).unwrap();
19531 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19532 assert_eq!(value["sandbox"]["enabled"], true);
19533 assert_eq!(
19534 value["sandbox"]["network"]["allowedDomains"],
19535 serde_json::json!(["github.com", "*.npmjs.org"])
19536 );
19537 assert!(settings_path.starts_with(&provider_home));
19538
19539 let acp = shared
19540 .resolve_network_allowlist_overlay_args(
19541 &agent("claude"),
19542 SessionMode::Acp,
19543 ApprovalLevel::Moderate,
19544 Some(&allowlist),
19545 Some(provider_home.as_path()),
19546 )
19547 .expect_err("Claude ACP must refuse settings overlay");
19548 assert_eq!(
19549 acp.code,
19550 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19551 );
19552
19553 let no_home = shared
19554 .resolve_network_allowlist_overlay_args(
19555 &agent("claude"),
19556 SessionMode::Pty,
19557 ApprovalLevel::Moderate,
19558 Some(&allowlist),
19559 None,
19560 )
19561 .expect_err("Claude overlay requires ProviderHome");
19562 assert_eq!(
19563 no_home.code,
19564 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19565 );
19566
19567 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19568 shared
19569 .network_allowlist_catalog
19570 .write()
19571 .unwrap_or_else(|poisoned| poisoned.into_inner())
19572 .insert_id(bare.clone())
19573 .unwrap();
19574 let empty = shared
19575 .resolve_network_allowlist_overlay_args(
19576 &agent("claude"),
19577 SessionMode::Pty,
19578 ApprovalLevel::Moderate,
19579 Some(&bare),
19580 Some(provider_home.as_path()),
19581 )
19582 .unwrap();
19583 assert!(empty.is_empty());
19584
19585 let _ = std::fs::remove_dir_all(&provider_home);
19586 }
19587
19588 #[test]
19590 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19591 let (shared, _defaults) = station_knobs_fixture();
19592 let allowlist =
19593 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19594 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19595 id: allowlist.clone(),
19596 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19597 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19598 peer: "example.com:443".to_owned(),
19599 }],
19600 provider_native: None,
19601 };
19602 shared
19603 .network_allowlist_catalog
19604 .write()
19605 .unwrap_or_else(|poisoned| poisoned.into_inner())
19606 .insert(entry)
19607 .unwrap();
19608 for provider in ["kimi", "grok"] {
19609 let overlay = shared
19610 .resolve_network_allowlist_overlay_args(
19611 &agent(provider),
19612 SessionMode::Pty,
19613 ApprovalLevel::Moderate,
19614 Some(&allowlist),
19615 None,
19616 )
19617 .unwrap();
19618 assert!(
19619 overlay.is_empty(),
19620 "{provider} must not invent network argv from permits"
19621 );
19622 }
19623 }
19624
19625 #[test]
19627 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19628 let unique = std::time::SystemTime::now()
19629 .duration_since(std::time::UNIX_EPOCH)
19630 .unwrap()
19631 .as_nanos();
19632 let path = std::env::temp_dir().join(format!(
19633 "gate4agent-node-allowlist-json-{}-{unique}.json",
19634 std::process::id(),
19635 ));
19636 std::fs::write(
19637 &path,
19638 r#"{
19639 "schema_version": 2,
19640 "entries": [
19641 {
19642 "id": "egress-default",
19643 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19644 },
19645 { "id": "lab-egress" }
19646 ]
19647 }"#,
19648 )
19649 .unwrap();
19650 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19651 .expect("json v2 catalog must load");
19652 let _ = std::fs::remove_file(&path);
19653 assert_eq!(loaded.len(), 2);
19654 let entry = loaded
19655 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19656 .unwrap();
19657 assert_eq!(entry.permits.len(), 1);
19658 let (shared, defaults) = station_knobs_fixture();
19659 {
19660 let mut catalog = shared
19661 .network_allowlist_catalog
19662 .write()
19663 .unwrap_or_else(|poisoned| poisoned.into_inner());
19664 *catalog = loaded;
19665 }
19666 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19667 let spec = crate::protocol::SpawnSpec {
19668 target: crate::protocol::SpawnTarget {
19669 node_id: shared.node_id.clone(),
19670 workspace_id: WorkspaceId::new("primary").unwrap(),
19671 worktree_id: None,
19672 },
19673 profile_id: defaults.profile_id.clone(),
19674 expected_profile_revision: defaults.revision.clone(),
19675 overrides: crate::protocol::SpawnOverrides {
19676 network_allowlist: Some(allowlist.clone()),
19677 ..crate::protocol::SpawnOverrides::default()
19678 },
19679 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19680 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19681 "station-knobs-catalog-json",
19682 )
19683 .unwrap(),
19684 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19685 };
19686 let resolved = spec.resolve(&defaults).unwrap();
19687 let receipt = shared
19688 .resolve_environment_profile(&resolved)
19689 .expect("permits-only entry ok for claude")
19690 .expect("env profile present");
19691 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19692 let inventory = shared
19693 .snapshot()
19694 .launch_inventory
19695 .expect("launch inventory present");
19696 let ids = inventory.network_allowlists.expect("ids surface");
19697 assert_eq!(
19698 ids,
19699 vec![
19700 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19701 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19702 ]
19703 );
19704 }
19705
19706
19707}
19708
19709
19710#[cfg(test)]
19711mod workspace_environment_collision_tests {
19712 use super::*;
19713 use crate::session_environment::{
19714 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19715 };
19716 use gate4agent_catalog::EnvMutation;
19717 use gate4agent_runtime_native::{
19718 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19719 NativeLaunchProfileId,
19720 };
19721 use std::ffi::OsString;
19722 use std::sync::Arc;
19723
19724 struct EmptyEnvironmentResolver;
19725
19726 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19727 fn resolve_child_environment(
19728 &self,
19729 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19730 Ok(vec![EnvMutation {
19731 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19732 value: None,
19733 }])
19734 }
19735 }
19736
19737 fn agent(value: &str) -> AgentId {
19738 AgentId::new(value).unwrap()
19739 }
19740
19741 fn claude_config_dir_collision_fixture() -> (
19743 NodeShared,
19744 ResolvedEnvironmentProfileReceipt,
19745 ResolvedEnvironmentProfileReceipt,
19746 ) {
19747 let catalog = active_registry().unwrap();
19748 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19749 let control = runtime.native_launch_profile_control();
19750 let workspace = WorkspaceConfig::new(
19751 WorkspaceId::new("primary").unwrap(),
19752 std::env::current_dir().unwrap(),
19753 )
19754 .unwrap();
19755 let shared = NodeShared::new_with_incarnation(
19756 handle,
19757 "fixture-token".to_owned(),
19758 NodeId::new("node-env-collision-test").unwrap(),
19759 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19760 vec![workspace],
19761 vec![agent("claude")],
19762 ProviderRuntimeStatuses::default(),
19763 None,
19764 Vec::new(),
19765 Vec::new(),
19766 SpawnProfileRegistry::default(),
19767 Some(control),
19768 None,
19769 None,
19770 None,
19771 Vec::new(),
19772 Vec::new(),
19773 Vec::new(),
19774 Vec::new(),
19775 None,
19776 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19777 );
19778
19779 let mut receipts = Vec::new();
19780 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19781 let profile_id =
19782 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19783 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19784 "claude-config-dir-{suffix}-r1"
19785 ))
19786 .unwrap();
19787 let materialization = NodeSessionMaterializationProfile::new(
19788 Vec::new(),
19789 vec![NodeSessionPathBinding::new(
19790 "CLAUDE_CONFIG_DIR",
19791 NodeSessionPathClass::ProviderHome,
19792 )
19793 .unwrap()],
19794 Vec::new(),
19795 )
19796 .unwrap();
19797 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19798 profile_id.clone(),
19799 profile_revision.clone(),
19800 agent("claude"),
19801 [NativeLaunchProfile::new(
19802 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19803 .unwrap(),
19804 agent("claude"),
19805 TransportKind::Pty,
19806 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19807 Arc::new(EmptyEnvironmentResolver),
19808 )
19809 .unwrap()],
19810 Some(materialization),
19811 )
19812 .unwrap();
19813 let (binding, native_profiles, materialization) = node_profile.into_parts();
19814 for native_profile in native_profiles {
19815 runtime.upsert_native_launch_profile(native_profile).unwrap();
19816 }
19817 shared
19818 .environment_profiles
19819 .write()
19820 .unwrap_or_else(|poisoned| poisoned.into_inner())
19821 .insert(binding.id.clone(), binding);
19822 shared
19823 .environment_materialization_profiles
19824 .write()
19825 .unwrap_or_else(|poisoned| poisoned.into_inner())
19826 .insert(profile_id.clone(), materialization.unwrap());
19827 receipts.push(ResolvedEnvironmentProfileReceipt {
19828 profile_id,
19829 profile_revision,
19830 network_allowlist: None,
19831 browser_profile_id: None,
19832 });
19833 }
19834 let profile_b = receipts.pop().unwrap();
19835 let profile_a = receipts.pop().unwrap();
19836 (shared, profile_a, profile_b)
19837 }
19838
19839 #[test]
19840 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19841 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19842 let primary = WorkspaceId::new("primary").unwrap();
19843 let worktree = WorkspaceId::new("worktree-b").unwrap();
19844 let address = SessionAddress {
19845 workspace_id: primary.clone(),
19846 session: SessionKey {
19847 instance_id: AgentInstanceId(41),
19848 generation: SessionGeneration(1),
19849 },
19850 };
19851 shared.bind_session_with_policy(
19852 &address,
19853 ProviderRuntimePolicy::raw_pty(),
19854 Some(profile_a.clone()),
19855 );
19856
19857 let refused = shared
19858 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19859 .unwrap_err();
19860 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19861
19862 let refused_none = shared
19864 .ensure_workspace_environment_profile_compatible(&primary, None)
19865 .unwrap_err();
19866 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19867
19868 shared
19870 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19871 .unwrap();
19872
19873 shared
19875 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19876 .unwrap();
19877 }
19878
19879 #[tokio::test]
19880 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19881 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19882 let primary = WorkspaceId::new("primary").unwrap();
19883 let address = SessionAddress {
19884 workspace_id: primary.clone(),
19885 session: SessionKey {
19886 instance_id: AgentInstanceId(41),
19887 generation: SessionGeneration(1),
19888 },
19889 };
19890 shared.bind_session_with_policy(
19891 &address,
19892 ProviderRuntimePolicy::raw_pty(),
19893 Some(profile_a),
19894 );
19895
19896 let failure = shared
19897 .spawn_session_with_deadline(
19898 primary,
19899 agent("claude"),
19900 SessionMode::Pty,
19901 gate4agent_types::TerminalSize {
19902 rows: 24,
19903 columns: 80,
19904 },
19905 None,
19906 Some(profile_b),
19907 None,
19908 None,
19909 None,
19910 None,
19911 SpawnRecordPolicy::ProviderIdentityOnly,
19912 Some(Instant::now() + Duration::from_secs(30)),
19913 &[],
19914 None,
19915 ApprovalLevel::default(),
19916 None,
19917 None,
19918 )
19919 .await
19920 .unwrap_err();
19921 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19922 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19924 assert!(shared.handle.snapshot().sessions.is_empty());
19925 }
19926}
19927
19928#[cfg(test)]
19929mod spawn_characterization_tests {
19930 use super::*;
19931 use crate::session_environment::{
19932 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19933 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19934 NodeSessionPathClass,
19935 };
19936 use gate4agent_catalog::EnvMutation;
19937 use gate4agent_runtime_native::{
19938 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19939 NativeLaunchProfile, NativeLaunchProfileId,
19940 };
19941 use std::ffi::OsString;
19942 use std::sync::Arc;
19943
19944 struct EmptyEnvironmentResolver;
19945
19946 struct FixtureSecretResolver {
19947 deny: Arc<AtomicBool>,
19948 }
19949
19950 impl NodeSecretResolver for FixtureSecretResolver {
19951 fn resolve(
19952 &self,
19953 _reference: &NodeSecretReference,
19954 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19955 if self.deny.load(Ordering::Acquire) {
19956 Err(NodeSecretResolveError::Denied)
19957 } else {
19958 NodeSecretValue::text("fixture-secret")
19959 .map_err(|_| NodeSecretResolveError::Unavailable)
19960 }
19961 }
19962 }
19963
19964 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19965 fn resolve_child_environment(
19966 &self,
19967 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19968 Ok(vec![EnvMutation {
19969 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19970 value: None,
19971 }])
19972 }
19973 }
19974
19975 fn agent(value: &str) -> AgentId {
19976 AgentId::new(value).unwrap()
19977 }
19978
19979 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19980 let unique = std::time::SystemTime::now()
19981 .duration_since(std::time::UNIX_EPOCH)
19982 .unwrap()
19983 .as_nanos();
19984 std::env::temp_dir().join(format!(
19985 "gate4agent-node-{label}-{}-{unique}",
19986 std::process::id(),
19987 ))
19988 }
19989
19990 fn materialization_test_shared(
19991 deny_secret: bool,
19992 ) -> (
19993 NodeShared,
19994 NativeRuntime,
19995 ResolvedEnvironmentProfileReceipt,
19996 PathBuf,
19997 Arc<AtomicBool>,
19998 ) {
19999 let workspace = WorkspaceConfig::new(
20000 WorkspaceId::new("primary").unwrap(),
20001 std::env::current_dir().unwrap(),
20002 )
20003 .unwrap();
20004 materialization_test_shared_with_workspace(deny_secret, workspace)
20005 }
20006
20007 fn materialization_test_shared_with_workspace(
20008 deny_secret: bool,
20009 workspace: WorkspaceConfig,
20010 ) -> (
20011 NodeShared,
20012 NativeRuntime,
20013 ResolvedEnvironmentProfileReceipt,
20014 PathBuf,
20015 Arc<AtomicBool>,
20016 ) {
20017 let root = temporary_workspace_root(if deny_secret {
20018 "materialization-denied"
20019 } else {
20020 "materialization-ready"
20021 });
20022 std::fs::create_dir_all(&root).unwrap();
20023 let materialization_root = root.join("session-environments");
20024 let deny = Arc::new(AtomicBool::new(deny_secret));
20025 let resolver = Arc::new(FixtureSecretResolver {
20026 deny: Arc::clone(&deny),
20027 });
20028 let materializer = SessionEnvironmentMaterializer::new(
20029 materialization_root,
20030 resolver,
20031 )
20032 .unwrap();
20033 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20034 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20035 "materialized-claude-r1",
20036 )
20037 .unwrap();
20038 let materialization = NodeSessionMaterializationProfile::new(
20039 vec![NodeSessionEnvironmentMutation::SetSecret {
20040 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20041 reference: NodeSecretReference::new("fixture-token").unwrap(),
20042 }],
20043 vec![NodeSessionPathBinding::new(
20044 "GATE4AGENT_PROVIDER_HOME",
20045 NodeSessionPathClass::ProviderHome,
20046 )
20047 .unwrap()],
20048 vec![NodeSessionFile::secret(
20049 NodeSessionPathClass::Config,
20050 "auth/token",
20051 NodeSecretReference::new("fixture-token").unwrap(),
20052 )
20053 .unwrap()],
20054 )
20055 .unwrap();
20056 let catalog = active_registry().unwrap();
20057 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20058 let control = runtime.native_launch_profile_control();
20059 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20060 profile_id.clone(),
20061 profile_revision.clone(),
20062 agent("claude"),
20063 [NativeLaunchProfile::new(
20064 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20065 agent("claude"),
20066 TransportKind::Pty,
20067 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20068 Arc::new(EmptyEnvironmentResolver),
20069 )
20070 .unwrap()],
20071 Some(materialization),
20072 )
20073 .unwrap();
20074 let (binding, native_profiles, materialization) = node_profile.into_parts();
20075 for native_profile in native_profiles {
20076 runtime.upsert_native_launch_profile(native_profile).unwrap();
20077 }
20078 let shared = NodeShared::new_with_incarnation(
20079 handle,
20080 "fixture-token".to_owned(),
20081 NodeId::new("node-materialization-test").unwrap(),
20082 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20083 vec![workspace],
20084 vec![agent("claude")],
20085 ProviderRuntimeStatuses::default(),
20086 None,
20087 Vec::new(),
20088 Vec::new(),
20089 SpawnProfileRegistry::default(),
20090 Some(control),
20091 None,
20092 Some(materializer),
20093 None,
20094 Vec::new(),
20095 Vec::new(),
20096 Vec::new(),
20097 Vec::new(),
20098 None,
20099 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20100 );
20101 shared
20102 .environment_profiles
20103 .write()
20104 .unwrap_or_else(|poisoned| poisoned.into_inner())
20105 .insert(binding.id.clone(), binding);
20106 shared
20107 .environment_materialization_profiles
20108 .write()
20109 .unwrap_or_else(|poisoned| poisoned.into_inner())
20110 .insert(profile_id.clone(), materialization.unwrap());
20111 (
20112 shared,
20113 runtime,
20114 ResolvedEnvironmentProfileReceipt {
20115 profile_id,
20116 profile_revision,
20117 network_allowlist: None,
20118 browser_profile_id: None,
20119 },
20120 root,
20121 deny,
20122 )
20123 }
20124
20125
20126 async fn characterization_spawn(
20129 shared: &NodeShared,
20130 mode: SessionMode,
20131 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20132 required: &[ProviderRuntimeCapability],
20133 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20134 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20135 shared
20136 .spawn_session_with_deadline(
20137 WorkspaceId::new("primary").unwrap(),
20138 agent("claude"),
20139 mode,
20140 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20141 None,
20142 environment_profile,
20143 None,
20144 None,
20145 None,
20146 Some(ProviderRuntimePolicy::raw_pty()),
20147 SpawnRecordPolicy::ProviderIdentityOnly,
20148 Some(Instant::now() + Duration::from_secs(30)),
20149 required,
20150 harness_mcp,
20151 ApprovalLevel::default(),
20152 None,
20153 None,
20154 )
20155 .await
20156 }
20157
20158 fn assert_no_session_state(shared: &NodeShared) {
20159 assert!(shared.handle.snapshot().sessions.is_empty());
20160 assert!(shared
20161 .session_bindings
20162 .lock()
20163 .unwrap_or_else(|poisoned| poisoned.into_inner())
20164 .is_empty());
20165 assert!(shared
20166 .materializations
20167 .lock()
20168 .unwrap_or_else(|poisoned| poisoned.into_inner())
20169 .is_empty());
20170 }
20171
20172 #[tokio::test]
20173 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20174 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20175 let error = characterization_spawn(
20176 &shared,
20177 SessionMode::Pty,
20178 None,
20179 &[ProviderRuntimeCapability::SemanticReadiness],
20180 None,
20181 )
20182 .await
20183 .unwrap_err();
20184 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20185 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20186 assert_no_session_state(&shared);
20187 drop(shared);
20188 drop(runtime);
20189 std::fs::remove_dir_all(root).unwrap();
20190 }
20191
20192 #[tokio::test]
20193 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20194 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20195 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20196 .await
20197 .unwrap_err();
20198 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20199 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20200 assert_no_session_state(&shared);
20201 drop(shared);
20202 drop(runtime);
20203 std::fs::remove_dir_all(root).unwrap();
20204 }
20205
20206 #[test]
20207 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20208 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20209 let address = SessionAddress {
20210 workspace_id: WorkspaceId::new("primary").unwrap(),
20211 session: SessionKey {
20212 instance_id: AgentInstanceId(77),
20213 generation: SessionGeneration(1),
20214 },
20215 };
20216 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20217 let removed = shared.remove_binding(&address).expect("binding removed");
20218 assert!(removed.environment_profile.is_some());
20219 assert!(shared.remove_binding(&address).is_none());
20220 assert_no_session_state(&shared);
20221 drop(shared);
20222 drop(runtime);
20223 std::fs::remove_dir_all(root).unwrap();
20224 }
20225
20226 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20227 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20228 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20229 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20230 .expect("review current test executable as fixture helper");
20231 let registry = HarnessMcpProxyRegistry::new(helper);
20232 let spec = crate::protocol::SpawnSpec {
20233 target: crate::protocol::SpawnTarget {
20234 node_id: NodeId::new("node-characterization").unwrap(),
20235 workspace_id: WorkspaceId::new("primary").unwrap(),
20236 worktree_id: None,
20237 },
20238 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20239 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20240 overrides: crate::protocol::SpawnOverrides::default(),
20241 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20242 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20243 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20244 };
20245 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20246 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20247 let launch = HarnessMcpLaunchV1 {
20248 server_name: "fixture-harness".to_owned(),
20249 args: vec!["--session-proxy".to_owned()],
20250 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20251 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20252 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20253 trace: None,
20254 scrub_env: Vec::new(),
20255 };
20256 let expires = unix_time_ms().saturating_add(30_000);
20257 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20258 registry
20259 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20260 .unwrap()
20261 }
20262
20263 #[tokio::test]
20264 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20265 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20266 let prepared = characterization_prepared_harness("claude").await;
20267 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20268 .await
20269 .unwrap_err();
20270 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20272 let mismatched = characterization_prepared_harness("codex").await;
20273 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20274 .await
20275 .unwrap_err();
20276 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20277 assert_no_session_state(&shared);
20278 drop(shared);
20279 drop(runtime);
20280 std::fs::remove_dir_all(root).unwrap();
20281 }
20282
20283 #[tokio::test]
20284 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20285 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20286 shared.native_launch_profile_control = None;
20287 let prepared = characterization_prepared_harness("claude").await;
20288 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20289 .await
20290 .unwrap_err();
20291 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20292 assert_no_session_state(&shared);
20293 drop(shared);
20294 drop(runtime);
20295 std::fs::remove_dir_all(root).unwrap();
20296 }
20297}
20298
20299#[cfg(all(test, windows))]
20300mod tests {
20301 use super::*;
20302 use gate4agent_types::ProviderActivity;
20303 use crate::session_environment::{
20304 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20305 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20306 NodeSessionPathClass,
20307 };
20308 use gate4agent_catalog::EnvMutation;
20309 use gate4agent_runtime_native::{
20310 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20311 NativeLaunchProfile, NativeLaunchProfileId,
20312 };
20313 use std::ffi::OsString;
20314 use std::sync::Arc;
20315
20316 struct EmptyEnvironmentResolver;
20317
20318 struct FixtureSecretResolver {
20319 deny: Arc<AtomicBool>,
20320 }
20321
20322 impl NodeSecretResolver for FixtureSecretResolver {
20323 fn resolve(
20324 &self,
20325 _reference: &NodeSecretReference,
20326 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20327 if self.deny.load(Ordering::Acquire) {
20328 Err(NodeSecretResolveError::Denied)
20329 } else {
20330 NodeSecretValue::text("fixture-secret")
20331 .map_err(|_| NodeSecretResolveError::Unavailable)
20332 }
20333 }
20334 }
20335
20336 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20337 fn resolve_child_environment(
20338 &self,
20339 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20340 Ok(vec![EnvMutation {
20341 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20342 value: None,
20343 }])
20344 }
20345 }
20346
20347 fn agent(value: &str) -> AgentId {
20348 AgentId::new(value).unwrap()
20349 }
20350
20351 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20352 let catalog = active_registry().unwrap();
20353 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20354 let workspace = WorkspaceConfig::new(
20355 WorkspaceId::new("primary").unwrap(),
20356 std::env::current_dir().unwrap(),
20357 )
20358 .unwrap()
20359 .with_worktree_service_mode(mode);
20360 NodeShared::new(
20361 handle,
20362 "fixture-token".to_owned(),
20363 NodeId::new("node-terminal-test").unwrap(),
20364 vec![workspace],
20365 vec![agent("claude")],
20366 )
20367 }
20368
20369 fn terminal_test_shared() -> NodeShared {
20370 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20371 }
20372
20373 #[cfg(feature = "fixture")]
20374 #[test]
20375 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20376 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20377 assert_eq!(probe.latest_code(), None);
20378
20379 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20380 assert_eq!(
20381 probe.latest_code(),
20382 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20383 );
20384
20385 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20386 assert_eq!(
20387 probe.latest_code(),
20388 Some(NodeFailureCode::SpawnDeadlineExceeded),
20389 );
20390
20391 probe.record(None);
20392 assert_eq!(probe.latest_code(), None);
20393 }
20394
20395 #[test]
20396 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20397 let source = temporary_workspace_root("launch-inventory-source");
20398 let allocation = temporary_workspace_root("launch-inventory-allocation");
20399 std::fs::create_dir_all(&source).unwrap();
20400 std::fs::create_dir_all(&allocation).unwrap();
20401 let workspace_id = WorkspaceId::new("primary").unwrap();
20402 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20403 .unwrap()
20404 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20405 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20406 workspace = workspace
20407 .with_managed_worktree_profile(
20408 ManagedWorktreeProfile::new(
20409 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20410 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20411 &allocation,
20412 "gate4agent",
20413 "HEAD",
20414 ManagedWorktreeRetention::RemoveWhenReleased,
20415 )
20416 .unwrap(),
20417 )
20418 .unwrap();
20419 }
20420 let overflow = workspace.clone().with_managed_worktree_profile(
20421 ManagedWorktreeProfile::new(
20422 WorktreeProfileId::new("overflow").unwrap(),
20423 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20424 &allocation,
20425 "gate4agent",
20426 "HEAD",
20427 ManagedWorktreeRetention::Retain,
20428 )
20429 .unwrap(),
20430 );
20431 assert!(matches!(
20432 overflow,
20433 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20434 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20435 ..
20436 }),
20437 ));
20438
20439 let catalog = active_registry().unwrap();
20440 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20441 let shared = NodeShared::new(
20442 handle,
20443 "fixture-token".to_owned(),
20444 NodeId::new("node-launch-inventory").unwrap(),
20445 vec![workspace],
20446 vec![agent("claude")],
20447 );
20448 let snapshot = shared.snapshot();
20449 let launch = snapshot.launch_inventory.as_ref().unwrap();
20450 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20451 assert!(launch.bundles.as_ref().unwrap().is_empty());
20452 let profiles = snapshot.workspaces[0]
20453 .managed_worktree_profiles
20454 .as_ref()
20455 .unwrap();
20456 assert_eq!(
20457 snapshot.workspaces[0].worktree_service_mode,
20458 Some(WorktreeServiceMode::Managed),
20459 );
20460 assert_eq!(
20461 profiles.profiles.len(),
20462 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20463 );
20464 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20465 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20466 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20467 }
20468 let legacy = snapshot_for_wire(
20469 &shared,
20470 true,
20471 true,
20472 false,
20473 false,
20474 true,
20475 false,
20476 true,
20477 false,
20478 true,
20479 );
20480 assert!(legacy.launch_inventory.is_none());
20481 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20482
20483 drop(shared);
20484 std::fs::remove_dir_all(source).unwrap();
20485 std::fs::remove_dir_all(allocation).unwrap();
20486 }
20487
20488 #[test]
20489 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20490 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20491 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20492 }));
20493 let source = gate4agent_types::ProviderSource {
20494 family: AdapterFamily::PtySemantic,
20495 binding: AdapterBinding::new(
20496 gate4agent_types::AdapterId::new("claude").unwrap(),
20497 "fixture/v1",
20498 gate4agent_types::AdapterVerification::SyntheticFixture,
20499 )
20500 .unwrap(),
20501 };
20502 let provider = ProviderSnapshot {
20503 sequence: 17,
20504 session: Some(ProviderSessionIdentity {
20505 key: ProviderSessionKey::SessionId,
20506 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20507 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20508 }),
20509 model: Some("PRIVATE_MODEL".to_owned()),
20510 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20511 completed_turns: 3,
20512 usage: gate4agent_types::TokenUsage {
20513 input_tokens: 10,
20514 output_tokens: 20,
20515 cache_read_tokens: 30,
20516 cache_write_tokens: 40,
20517 reasoning_tokens: 50,
20518 context_window: Some(200_000),
20519 },
20520 lead_activity: ProviderActivity::WaitingForInput,
20521 activity: ProviderActivity::WaitingForInput,
20522 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20523 active_tools: (0..9)
20524 .map(|index| gate4agent_types::ActiveProviderTool {
20525 id: format!("private-tool-id-{index}"),
20526 name: if index == 0 {
20527 format!(" Read\u{0000}{}", "x".repeat(80))
20528 } else {
20529 format!("Tool-{index}")
20530 },
20531 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20532 })
20533 .collect(),
20534 interactions: vec![gate4agent_types::ProviderInteraction {
20535 id: gate4agent_types::ProviderInteractionId(17),
20536 source: source.clone(),
20537 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20538 interaction_kind: ProviderInteractionKind::Approval,
20539 tool_name: " Approve\u{0007}Tool ".to_owned(),
20540 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20541 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20542 resume_lead_activity: Some(ProviderActivity::Working),
20543 status: ProviderInteractionStatus::Pending,
20544 }],
20545 subagents: vec![gate4agent_types::ProviderSubagent {
20546 source: source.clone(),
20547 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20548 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20549 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20550 }],
20551 sources: vec![gate4agent_types::ProviderSourceCursor {
20552 source,
20553 sequence: 17,
20554 gap_count: 2,
20555 stale: true,
20556 }],
20557 last_event: Some(ProviderEvent::Error {
20558 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20559 }),
20560 gap_count: 2,
20561 stale: true,
20562 };
20563 let address = terminal_address(3);
20564 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20565 assert_eq!(entry.address, address);
20566 assert_eq!(entry.progress.provider_sequence, 17);
20567 assert_eq!(entry.progress.active_tool_count, 9);
20568 assert_eq!(
20569 entry.progress.active_tool_labels.len(),
20570 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20571 );
20572 assert_eq!(entry.progress.subagent_count, 1);
20573 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20574 assert!(entry.progress.truncated);
20575 let encoded = serde_json::to_string(&entry).unwrap();
20576 for forbidden in [
20577 "PRIVATE_PROVIDER_SESSION",
20578 "PRIVATE_PROVIDER_PATH",
20579 "PRIVATE_MODEL",
20580 "PRIVATE_TOOL_INVENTORY",
20581 "PRIVATE_CURRENT_PROMPT",
20582 "PRIVATE_TOOL_INPUT_JSON",
20583 "PRIVATE_PROVIDER_REQUEST",
20584 "PRIVATE_INTERACTION_PROMPT",
20585 "PRIVATE_INTERACTION_AGENT",
20586 "PRIVATE_SUBAGENT_ID",
20587 "PRIVATE_SUBAGENT_TYPE",
20588 "PRIVATE_SUBAGENT_DESCRIPTION",
20589 "PRIVATE_PROVIDER_ERROR",
20590 "input_json",
20591 "current_prompt",
20592 "session_id",
20593 ] {
20594 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20595 }
20596
20597 let snapshot = NodeSnapshot {
20598 node_id: NodeId::new("node-progress-test").unwrap(),
20599 enabled_providers: vec![agent("claude")],
20600 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20601 workspaces: Vec::new(),
20602 session_records: Vec::new(),
20603 managed_worktrees: Vec::new(),
20604 launch_inventory: None,
20605 agent_progress: vec![entry],
20606 };
20607 let mut legacy_reply = ResponseEnvelope {
20608 request_id: 1,
20609 result: Ok(NodeResponse::Snapshot {
20610 event_sequence: 0,
20611 controller: None,
20612 snapshot: snapshot.clone(),
20613 }),
20614 };
20615 project_response_without_agent_progress(&mut legacy_reply);
20616 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20617 panic!("agent progress projection changed response kind");
20618 };
20619 assert!(legacy.agent_progress.is_empty());
20620 assert_eq!(snapshot.agent_progress.len(), 1);
20621 }
20622
20623 fn terminal_address(generation: u64) -> SessionAddress {
20624 SessionAddress {
20625 workspace_id: WorkspaceId::new("primary").unwrap(),
20626 session: SessionKey {
20627 instance_id: AgentInstanceId(41),
20628 generation: SessionGeneration(generation),
20629 },
20630 }
20631 }
20632
20633 fn spawn_spec_fixture() -> SpawnSpec {
20634 SpawnSpec {
20635 target: crate::protocol::SpawnTarget {
20636 node_id: NodeId::new("node-terminal-test").unwrap(),
20637 workspace_id: WorkspaceId::new("primary").unwrap(),
20638 worktree_id: None,
20639 },
20640 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20641 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20642 .unwrap(),
20643 overrides: crate::protocol::SpawnOverrides::default(),
20644 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20645 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20646 required_capabilities: SpawnRequiredCapabilities::default(),
20647 }
20648 }
20649
20650 fn environment_profile_test_shared(
20651 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20652 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20653 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20654 "local-claude-r1",
20655 )
20656 .unwrap();
20657 let spawn_profiles = SpawnProfileRegistry::new([
20658 crate::protocol::SpawnProfileDefaults {
20659 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20660 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20661 provider: agent("claude"),
20662 mode: SessionMode::Pty,
20663 terminal_size: gate4agent_types::TerminalSize {
20664 rows: 24,
20665 columns: 80,
20666 },
20667 prompt: None,
20668 bundle_id: None,
20669 context_id: None,
20670 environment_profile_id: Some(profile_id.clone()),
20671 },
20672 ])
20673 .unwrap();
20674 let catalog = active_registry().unwrap();
20675 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20676 let control = runtime.native_launch_profile_control();
20677 let node_profile = NodeEnvironmentProfile::new(
20678 profile_id.clone(),
20679 profile_revision.clone(),
20680 agent("claude"),
20681 [NativeLaunchProfile::new(
20682 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20683 agent("claude"),
20684 TransportKind::Pty,
20685 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20686 Arc::new(EmptyEnvironmentResolver),
20687 )
20688 .unwrap()],
20689 )
20690 .unwrap();
20691 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20692 for native_profile in native_profiles {
20693 runtime.upsert_native_launch_profile(native_profile).unwrap();
20694 }
20695 let workspace = WorkspaceConfig::new(
20696 WorkspaceId::new("primary").unwrap(),
20697 std::env::current_dir().unwrap(),
20698 )
20699 .unwrap();
20700 let shared = NodeShared::new_with_incarnation(
20701 handle,
20702 "fixture-token".to_owned(),
20703 NodeId::new("node-terminal-test").unwrap(),
20704 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20705 vec![workspace],
20706 vec![agent("claude")],
20707 ProviderRuntimeStatuses::default(),
20708 None,
20709 Vec::new(),
20710 Vec::new(),
20711 spawn_profiles,
20712 Some(control),
20713 None,
20714 None,
20715 None,
20716 Vec::new(),
20717 Vec::new(),
20718 Vec::new(),
20719 Vec::new(),
20720 None,
20721 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20722 );
20723 shared
20724 .environment_profiles
20725 .write()
20726 .unwrap_or_else(|poisoned| poisoned.into_inner())
20727 .insert(binding.id.clone(), binding);
20728 (
20729 shared,
20730 runtime,
20731 ResolvedEnvironmentProfileReceipt {
20732 profile_id,
20733 profile_revision,
20734 network_allowlist: None,
20735 browser_profile_id: None,
20736 },
20737 )
20738 }
20739
20740 fn materialization_test_shared(
20741 deny_secret: bool,
20742 ) -> (
20743 NodeShared,
20744 NativeRuntime,
20745 ResolvedEnvironmentProfileReceipt,
20746 PathBuf,
20747 Arc<AtomicBool>,
20748 ) {
20749 let workspace = WorkspaceConfig::new(
20750 WorkspaceId::new("primary").unwrap(),
20751 std::env::current_dir().unwrap(),
20752 )
20753 .unwrap();
20754 materialization_test_shared_with_workspace(deny_secret, workspace)
20755 }
20756
20757 fn materialization_test_shared_with_workspace(
20758 deny_secret: bool,
20759 workspace: WorkspaceConfig,
20760 ) -> (
20761 NodeShared,
20762 NativeRuntime,
20763 ResolvedEnvironmentProfileReceipt,
20764 PathBuf,
20765 Arc<AtomicBool>,
20766 ) {
20767 let root = temporary_workspace_root(if deny_secret {
20768 "materialization-denied"
20769 } else {
20770 "materialization-ready"
20771 });
20772 std::fs::create_dir_all(&root).unwrap();
20773 let materialization_root = root.join("session-environments");
20774 let deny = Arc::new(AtomicBool::new(deny_secret));
20775 let resolver = Arc::new(FixtureSecretResolver {
20776 deny: Arc::clone(&deny),
20777 });
20778 let materializer = SessionEnvironmentMaterializer::new(
20779 materialization_root,
20780 resolver,
20781 )
20782 .unwrap();
20783 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20784 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20785 "materialized-claude-r1",
20786 )
20787 .unwrap();
20788 let materialization = NodeSessionMaterializationProfile::new(
20789 vec![NodeSessionEnvironmentMutation::SetSecret {
20790 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20791 reference: NodeSecretReference::new("fixture-token").unwrap(),
20792 }],
20793 vec![NodeSessionPathBinding::new(
20794 "GATE4AGENT_PROVIDER_HOME",
20795 NodeSessionPathClass::ProviderHome,
20796 )
20797 .unwrap()],
20798 vec![NodeSessionFile::secret(
20799 NodeSessionPathClass::Config,
20800 "auth/token",
20801 NodeSecretReference::new("fixture-token").unwrap(),
20802 )
20803 .unwrap()],
20804 )
20805 .unwrap();
20806 let catalog = active_registry().unwrap();
20807 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20808 let control = runtime.native_launch_profile_control();
20809 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20810 profile_id.clone(),
20811 profile_revision.clone(),
20812 agent("claude"),
20813 [NativeLaunchProfile::new(
20814 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20815 agent("claude"),
20816 TransportKind::Pty,
20817 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20818 Arc::new(EmptyEnvironmentResolver),
20819 )
20820 .unwrap()],
20821 Some(materialization),
20822 )
20823 .unwrap();
20824 let (binding, native_profiles, materialization) = node_profile.into_parts();
20825 for native_profile in native_profiles {
20826 runtime.upsert_native_launch_profile(native_profile).unwrap();
20827 }
20828 let shared = NodeShared::new_with_incarnation(
20829 handle,
20830 "fixture-token".to_owned(),
20831 NodeId::new("node-materialization-test").unwrap(),
20832 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20833 vec![workspace],
20834 vec![agent("claude")],
20835 ProviderRuntimeStatuses::default(),
20836 None,
20837 Vec::new(),
20838 Vec::new(),
20839 SpawnProfileRegistry::default(),
20840 Some(control),
20841 None,
20842 Some(materializer),
20843 None,
20844 Vec::new(),
20845 Vec::new(),
20846 Vec::new(),
20847 Vec::new(),
20848 None,
20849 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20850 );
20851 shared
20852 .environment_profiles
20853 .write()
20854 .unwrap_or_else(|poisoned| poisoned.into_inner())
20855 .insert(binding.id.clone(), binding);
20856 shared
20857 .environment_materialization_profiles
20858 .write()
20859 .unwrap_or_else(|poisoned| poisoned.into_inner())
20860 .insert(profile_id.clone(), materialization.unwrap());
20861 (
20862 shared,
20863 runtime,
20864 ResolvedEnvironmentProfileReceipt {
20865 profile_id,
20866 profile_revision,
20867 network_allowlist: None,
20868 browser_profile_id: None,
20869 },
20870 root,
20871 deny,
20872 )
20873 }
20874
20875 #[cfg(feature = "fixture")]
20876 #[derive(Clone, Copy)]
20877 enum CodexHomeProfile {
20878 Missing,
20879 WrongClass,
20880 Exact,
20881 }
20882
20883 #[cfg(feature = "fixture")]
20884 fn codex_bundle_test_shared(
20885 home_profile: CodexHomeProfile,
20886 ) -> (
20887 NodeShared,
20888 NativeRuntime,
20889 ResolvedEnvironmentProfileReceipt,
20890 NodeBundle,
20891 PathBuf,
20892 ) {
20893 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20894 const CLAUDE_MANIFEST: &[u8] =
20895 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20896 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20897 const DIGEST: &str =
20898 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20899
20900 let (shared, mut runtime, _claude_receipt, root, _deny) =
20901 materialization_test_shared(false);
20902 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20903 CodexHomeProfile::Missing => "codex-home-missing",
20904 CodexHomeProfile::WrongClass => "codex-home-wrong",
20905 CodexHomeProfile::Exact => "codex-home-exact",
20906 })
20907 .unwrap();
20908 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20909 "codex-home-r1",
20910 )
20911 .unwrap();
20912 let materialization = match home_profile {
20913 CodexHomeProfile::Missing => None,
20914 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20915 Some(NodeSessionMaterializationProfile::new(
20916 Vec::new(),
20917 vec![NodeSessionPathBinding::new(
20918 "CODEX_HOME",
20919 if matches!(home_profile, CodexHomeProfile::Exact) {
20920 NodeSessionPathClass::ProviderHome
20921 } else {
20922 NodeSessionPathClass::Config
20923 },
20924 )
20925 .unwrap()],
20926 Vec::new(),
20927 )
20928 .unwrap())
20929 }
20930 };
20931 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20932 profile_id.clone(),
20933 profile_revision.clone(),
20934 agent("codex"),
20935 [NativeLaunchProfile::new(
20936 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20937 agent("codex"),
20938 TransportKind::Pty,
20939 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20940 Arc::new(EmptyEnvironmentResolver),
20941 )
20942 .unwrap()],
20943 materialization,
20944 )
20945 .unwrap();
20946 let (binding, native_profiles, materialization) = node_profile.into_parts();
20947 for native_profile in native_profiles {
20948 runtime.upsert_native_launch_profile(native_profile).unwrap();
20949 }
20950 shared
20951 .environment_profiles
20952 .write()
20953 .unwrap_or_else(|poisoned| poisoned.into_inner())
20954 .insert(binding.id.clone(), binding);
20955 if let Some(materialization) = materialization {
20956 shared
20957 .environment_materialization_profiles
20958 .write()
20959 .unwrap_or_else(|poisoned| poisoned.into_inner())
20960 .insert(profile_id.clone(), materialization);
20961 }
20962
20963 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20964 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20965 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20966 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20967 std::fs::write(
20968 bundle_source.join(".claude-plugin/plugin.json"),
20969 CLAUDE_MANIFEST,
20970 )
20971 .unwrap();
20972 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20973 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20974 let bundle = NodeBundle::new(
20975 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20976 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20977 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20978 &bundle_source,
20979 )
20980 .unwrap();
20981 *shared
20982 .bundle_catalog
20983 .write()
20984 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20985 BundleCatalog::new([bundle.clone()]).unwrap();
20986 (
20987 shared,
20988 runtime,
20989 ResolvedEnvironmentProfileReceipt {
20990 profile_id,
20991 profile_revision,
20992 network_allowlist: None,
20993 browser_profile_id: None,
20994 },
20995 bundle,
20996 root,
20997 )
20998 }
20999
21000 #[cfg(feature = "fixture")]
21001 #[test]
21002 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
21003 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
21004 let (shared, runtime, receipt, bundle, root) =
21005 codex_bundle_test_shared(home_profile);
21006 let error = shared
21007 .bundle_layout(
21008 &agent("codex"),
21009 SessionMode::Pty,
21010 Some(&receipt),
21011 &bundle,
21012 )
21013 .unwrap_err();
21014 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21015 assert!(shared
21016 .materializations
21017 .lock()
21018 .unwrap_or_else(|poisoned| poisoned.into_inner())
21019 .is_empty());
21020 let materialization_root = root.join("session-environments");
21021 assert!(!std::fs::read_dir(&materialization_root)
21022 .unwrap()
21023 .filter_map(Result::ok)
21024 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21025 drop(shared);
21026 drop(runtime);
21027 let _ = std::fs::remove_dir_all(root);
21028 }
21029 }
21030
21031 #[cfg(feature = "fixture")]
21032 #[test]
21033 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21034 let (shared, runtime, receipt, bundle, root) =
21035 codex_bundle_test_shared(CodexHomeProfile::Exact);
21036 let address = SessionAddress {
21037 workspace_id: WorkspaceId::new("primary").unwrap(),
21038 session: SessionKey {
21039 instance_id: AgentInstanceId(310),
21040 generation: SessionGeneration(1),
21041 },
21042 };
21043 let (overlay, guard) = shared
21044 .prepare_session_materialization(
21045 &address,
21046 &agent("codex"),
21047 SessionMode::Pty,
21048 Some(&receipt),
21049 Some(&bundle.receipt()),
21050 None,
21051 None,
21052 )
21053 .unwrap()
21054 .unwrap();
21055 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21056 let ownership = shared
21057 .materializations
21058 .lock()
21059 .unwrap_or_else(|poisoned| poisoned.into_inner())
21060 .get(guard.id().unwrap())
21061 .unwrap()
21062 .clone();
21063 let (profile, _, _) = shared
21064 .materialization_profile(
21065 &agent("codex"),
21066 SessionMode::Pty,
21067 Some(&receipt),
21068 Some(&bundle.receipt()),
21069 None,
21070 )
21071 .unwrap()
21072 .unwrap();
21073 let environment = shared
21074 .session_environment_materializer
21075 .as_ref()
21076 .unwrap()
21077 .resolve_environment(&ownership, &profile)
21078 .unwrap();
21079 assert!(environment.iter().any(|mutation| {
21080 mutation.key == OsString::from("CODEX_HOME")
21081 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21082 }));
21083 assert_eq!(
21084 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21085 .unwrap(),
21086 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21087 );
21088 assert!(std::fs::read_dir(ownership.bundle_root())
21089 .unwrap()
21090 .next()
21091 .is_none());
21092 drop(guard);
21093 drop(shared);
21094 drop(runtime);
21095 let _ = std::fs::remove_dir_all(root);
21096 }
21097
21098 #[cfg(feature = "fixture")]
21099 #[test]
21100 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21101 let (shared, runtime, receipt, bundle, root) =
21102 codex_bundle_test_shared(CodexHomeProfile::Exact);
21103 let bundle_receipt = bundle.receipt();
21104 let address = SessionAddress {
21105 workspace_id: WorkspaceId::new("primary").unwrap(),
21106 session: SessionKey {
21107 instance_id: AgentInstanceId(311),
21108 generation: SessionGeneration(1),
21109 },
21110 };
21111 let (_overlay, guard) = shared
21112 .prepare_session_materialization(
21113 &address,
21114 &agent("codex"),
21115 SessionMode::Pty,
21116 Some(&receipt),
21117 Some(&bundle_receipt),
21118 None,
21119 None,
21120 )
21121 .unwrap()
21122 .unwrap();
21123 let materialization_id = guard.id().unwrap().clone();
21124 let skill_path = shared
21125 .materializations
21126 .lock()
21127 .unwrap_or_else(|poisoned| poisoned.into_inner())
21128 .get(&materialization_id)
21129 .unwrap()
21130 .provider_home()
21131 .join("skills/review-code/SKILL.md");
21132 let record_id = shared
21133 .bind_spawn_session_with_materialization(
21134 &address,
21135 agent("codex"),
21136 SessionMode::Pty,
21137 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21138 SpawnRecordPolicy::ProviderIdentityOnly,
21139 Some(receipt.clone()),
21140 Some(bundle_receipt.clone()),
21141 None,
21142 Some(materialization_id.clone()),
21143 )
21144 .unwrap()
21145 .unwrap();
21146 guard.retain();
21147 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21148
21149 let error = match shared.resolve_record_materialization(
21150 &record_id,
21151 &agent("codex"),
21152 SessionMode::Pty,
21153 Some(&receipt),
21154 Some(&bundle_receipt),
21155 None,
21156 ) {
21157 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21158 Err(error) => error,
21159 };
21160 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21161 assert_eq!(
21162 shared
21163 .materializations
21164 .lock()
21165 .unwrap_or_else(|poisoned| poisoned.into_inner())
21166 .get(&materialization_id)
21167 .unwrap()
21168 .state(),
21169 MaterializationState::RecoveryRequired,
21170 );
21171 drop(shared);
21172 drop(runtime);
21173 let _ = std::fs::remove_dir_all(root);
21174 }
21175
21176 fn bind_record_owned_materialization_fixture(
21177 shared: &NodeShared,
21178 receipt: &ResolvedEnvironmentProfileReceipt,
21179 instance_id: u64,
21180 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21181 let address = SessionAddress {
21182 workspace_id: WorkspaceId::new("primary").unwrap(),
21183 session: SessionKey {
21184 instance_id: AgentInstanceId(instance_id),
21185 generation: SessionGeneration(1),
21186 },
21187 };
21188 let (overlay, materialization_guard) = shared
21189 .prepare_session_materialization(
21190 &address,
21191 &agent("claude"),
21192 SessionMode::Pty,
21193 Some(receipt),
21194 None,
21195 None,
21196 None,
21197 )
21198 .unwrap()
21199 .unwrap();
21200 let materialization_id = materialization_guard.id().unwrap().clone();
21201 let materialization_root = shared
21202 .materializations
21203 .lock()
21204 .unwrap_or_else(|poisoned| poisoned.into_inner())
21205 .get(&materialization_id)
21206 .unwrap()
21207 .root()
21208 .to_path_buf();
21209 let selection = shared
21210 .select_environment_profile(
21211 address.session.instance_id,
21212 &agent("claude"),
21213 SessionMode::Pty,
21214 Some(receipt),
21215 )
21216 .unwrap()
21217 .unwrap();
21218 assert!(shared
21219 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21220 .unwrap()
21221 .is_none());
21222 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21223 let record_id = shared
21224 .bind_spawn_session_with_materialization(
21225 &address,
21226 agent("claude"),
21227 SessionMode::Pty,
21228 policy,
21229 SpawnRecordPolicy::ProviderIdentityOnly,
21230 Some(receipt.clone()),
21231 None,
21232 None,
21233 Some(materialization_id.clone()),
21234 )
21235 .unwrap()
21236 .unwrap();
21237 selection.retain();
21238 materialization_guard.retain();
21239 (address, record_id, materialization_id, materialization_root)
21240 }
21241
21242 #[test]
21243 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21244 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21245 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21246 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21247 }));
21248 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21249 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21250 let mut spec = spawn_spec_fixture();
21251 spec.expected_profile_revision =
21252 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21253 assert!(matches!(
21254 &spec.overrides.environment_profile_id,
21255 crate::protocol::SpawnOverride::Inherit,
21256 ));
21257 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21258 .requires_child_environment_profile_capability());
21259 assert!(request_requires_child_environment_profile(
21260 &shared,
21261 &NodeRequest::SpawnSpec { spec: spec.clone() },
21262 ));
21263
21264 let address = terminal_address(1);
21265 shared.bind_session_with_policy(
21266 &address,
21267 ProviderRuntimePolicy::raw_pty(),
21268 Some(environment_profile.clone()),
21269 );
21270 assert!(request_requires_child_environment_profile(
21271 &shared,
21272 &NodeRequest::Input {
21273 session: address.clone(),
21274 text: "x".to_owned(),
21275 },
21276 ));
21277
21278 let mut record = record("claude", "profile-record");
21279 record.active_session = Some(address.clone());
21280 record.environment_profile = Some(environment_profile.clone());
21281 let record_id = record.record_id.clone();
21282 shared.insert_record(record.clone()).unwrap();
21283 assert!(request_requires_child_environment_profile(
21284 &shared,
21285 &NodeRequest::ResumeSessionRecord {
21286 record_id,
21287 terminal_size: gate4agent_types::TerminalSize {
21288 rows: 24,
21289 columns: 80,
21290 },
21291 initial_prompt: None,
21292 },
21293 ));
21294
21295 let snapshot = snapshot_for_wire(
21296 &shared, false, true, true, true, false, false, false, false, true,
21297 );
21298 assert_eq!(snapshot.session_records.len(), 1);
21299 assert!(snapshot.session_records[0].environment_profile.is_none());
21300 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21301 sequence: 1,
21302 event: NodeEvent::SessionRecordUpserted { record },
21303 });
21304 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21305 panic!("record projection changed the event kind");
21306 };
21307 assert!(record.environment_profile.is_none());
21308
21309 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21310 let mut reply = ResponseEnvelope {
21311 request_id: 1,
21312 result: Ok(NodeResponse::SpawnSpecAccepted {
21313 receipt: resolved.receipt_with_environment(
21314 shared.incarnation_id,
21315 address,
21316 Some(environment_profile),
21317 ),
21318 }),
21319 };
21320 project_response_without_child_environment_profile(&mut reply);
21321 assert_eq!(
21322 reply.result.unwrap_err().code,
21323 NodeFailureCode::UnsupportedCapability,
21324 );
21325 }
21326
21327 #[test]
21328 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21329 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21330 let snapshot = shared.snapshot();
21331 let profiles = snapshot
21332 .launch_inventory
21333 .as_ref()
21334 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21335 .unwrap();
21336 assert_eq!(profiles.len(), 1);
21337 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21338 assert!(snapshot.requires_child_environment_profile_capability());
21339
21340 let mut legacy = snapshot.clone();
21341 clear_snapshot_child_environment_profiles(&mut legacy);
21342 assert!(legacy
21343 .launch_inventory
21344 .as_ref()
21345 .unwrap()
21346 .spawn_profiles
21347 .as_ref()
21348 .unwrap()[0]
21349 .environment_profile
21350 .is_none());
21351 assert!(!legacy.requires_child_environment_profile_capability());
21352
21353 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21354 let bindings = shared
21355 .environment_profiles
21356 .read()
21357 .unwrap_or_else(|poisoned| poisoned.into_inner())
21358 .clone();
21359 let mut provider_mismatch = bindings.clone();
21360 provider_mismatch
21361 .values_mut()
21362 .next()
21363 .unwrap()
21364 .provider = agent("codex");
21365 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21366 let mut mode_mismatch = profile;
21367 mode_mismatch.mode = SessionMode::Inline;
21368 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21369
21370 shared
21371 .environment_profiles
21372 .write()
21373 .unwrap_or_else(|poisoned| poisoned.into_inner())
21374 .clear();
21375 assert!(shared
21376 .snapshot()
21377 .launch_inventory
21378 .unwrap()
21379 .spawn_profiles
21380 .unwrap()
21381 .is_empty());
21382 }
21383
21384 #[test]
21385 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21386 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21387 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21388 }));
21389 let shared = terminal_test_shared();
21390 let bundle = ResolvedBundleReceipt {
21391 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21392 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21393 digest: crate::protocol::SpawnBundleDigest::new(format!(
21394 "sha256:{}",
21395 "0".repeat(64),
21396 ))
21397 .unwrap(),
21398 };
21399 let mut bundled = record("claude", "bundle-record");
21400 bundled.bundle = Some(bundle.clone());
21401 let record_id = bundled.record_id.clone();
21402 shared.insert_record(bundled.clone()).unwrap();
21403 assert!(request_requires_session_bundle(
21404 &shared,
21405 &NodeRequest::ForgetSessionRecord {
21406 record_id: record_id.clone(),
21407 },
21408 ));
21409
21410 let snapshot = snapshot_for_wire(
21411 &shared, false, true, true, true, true, false, false, false, true,
21412 );
21413 assert!(snapshot.session_records[0].bundle.is_none());
21414 let launch = snapshot.launch_inventory.as_ref().unwrap();
21415 assert!(launch.spawn_profiles.is_some());
21416 assert!(launch.bundles.is_none());
21417 let event = project_event_without_session_bundle(NodeEventEnvelope {
21418 sequence: 1,
21419 event: NodeEvent::SessionRecordUpserted { record: bundled },
21420 });
21421 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21422 panic!("record projection changed the event kind");
21423 };
21424 assert!(record.bundle.is_none());
21425
21426 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21427 let mut reply = ResponseEnvelope {
21428 request_id: 1,
21429 result: Ok(NodeResponse::SpawnSpecAccepted {
21430 receipt: resolved.receipt_with_materialization(
21431 shared.incarnation_id,
21432 terminal_address(17),
21433 None,
21434 Some(bundle),
21435 None,
21436 ),
21437 }),
21438 };
21439 project_response_without_session_bundle(&mut reply);
21440 assert_eq!(
21441 reply.result.unwrap_err().code,
21442 NodeFailureCode::UnsupportedCapability,
21443 );
21444 }
21445
21446 #[test]
21447 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21448 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21449 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21450 }));
21451 let context_id = SpawnContextId::new("context-review").unwrap();
21452 let context = ResolvedContextPackReceipt {
21453 id: context_id.clone(),
21454 digest: crate::protocol::SpawnContextDigest::new(format!(
21455 "sha256:{}",
21456 "c".repeat(64),
21457 ))
21458 .unwrap(),
21459 lineage: ContextPackLineageReceipt {
21460 source_node_id: NodeId::new("node-source").unwrap(),
21461 source_session: terminal_address(1),
21462 source_provider: agent("kimi"),
21463 },
21464 source_message_count: 2,
21465 retained_message_count: 2,
21466 byte_len: 256,
21467 truncated: false,
21468 };
21469 assert!(context.is_valid());
21470 let mut bound_record = record("codex", "context-record");
21471 bound_record.context_id = Some(context_id);
21472 bound_record.context = Some(context.clone());
21473 let session = gate4agent_types::SessionSnapshot {
21474 instance_id: AgentInstanceId(91),
21475 agent_id: agent("kimi"),
21476 transport: TransportKind::Pty,
21477 generation: SessionGeneration(3),
21478 status: SessionStatus::Running,
21479 pending_operation: None,
21480 pending_input: None,
21481 process_id: Some(9001),
21482 terminal_size: None,
21483 terminal_frame: None,
21484 terminal_stale: None,
21485 session_options: None,
21486 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21487 history: gate4agent_types::HistorySnapshot {
21488 pending: None,
21489 candidates: vec![HistoryCandidateSummary {
21490 id: "candidate-opaque".to_owned(),
21491 session_id_hint: "session-hint".to_owned(),
21492 modified_at_unix_ms: Some(1),
21493 }],
21494 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21495 loaded: Some(HistorySessionRecord {
21496 session_id: "private-vendor-session".to_owned(),
21497 title: Some("private title".to_owned()),
21498 cwd: Some(r"C:\private\history-root".to_owned()),
21499 model: Some("private-model".to_owned()),
21500 message_count: 2,
21501 completed_turn_count: None,
21502 total_tokens: 19,
21503 messages: vec![
21504 gate4agent_types::HistoryMessageRecord {
21505 role: gate4agent_types::HistoryMessageRole::User,
21506 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21507 },
21508 gate4agent_types::HistoryMessageRecord {
21509 role: gate4agent_types::HistoryMessageRole::Assistant,
21510 text: "private answer".to_owned(),
21511 },
21512 ],
21513 }),
21514 last_error: None,
21515 },
21516 resume: gate4agent_types::ResumeSnapshot::default(),
21517 foreground: gate4agent_types::ForegroundSnapshot::default(),
21518 provider: gate4agent_types::ProviderSnapshot::default(),
21519 screen_state: gate4agent_types::PtyScreenState::default(),
21520 };
21521 let original = NodeSnapshot {
21522 node_id: NodeId::new("node-terminal-test").unwrap(),
21523 enabled_providers: vec![agent("kimi"), agent("codex")],
21524 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21525 workspaces: vec![WorkspaceSnapshot {
21526 workspace_id: WorkspaceId::new("primary").unwrap(),
21527 canonical_root: opaque_windows_path(
21528 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21529 ),
21530 sessions: vec![session],
21531 worktree_service_mode: None,
21532 managed_worktree_profiles: None,
21533 }],
21534 session_records: vec![bound_record],
21535 managed_worktrees: Vec::new(),
21536 launch_inventory: None,
21537 agent_progress: Vec::new(),
21538 };
21539
21540 let mut negotiated = original.clone();
21541 project_snapshot_history_for_wire(&mut negotiated, true);
21542 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21543 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21544 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21545 assert!(!serde_json::to_string(&negotiated)
21546 .unwrap()
21547 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21548
21549 let mut legacy = original;
21550 project_snapshot_history_for_wire(&mut legacy, false);
21551 clear_snapshot_context_packs(&mut legacy);
21552 assert_eq!(
21553 legacy.workspaces[0].sessions[0].history,
21554 gate4agent_types::HistorySnapshot::default(),
21555 );
21556 assert!(legacy.session_records[0].context_id.is_none());
21557 assert!(legacy.session_records[0].context.is_none());
21558 let encoded = serde_json::to_string(&legacy).unwrap();
21559 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21560 assert!(!encoded.contains("context-review"));
21561
21562 let mut reply = ResponseEnvelope {
21563 request_id: 7,
21564 result: Ok(NodeResponse::ContextPackExported { context }),
21565 };
21566 project_response_without_context_pack(&mut reply);
21567 assert_eq!(
21568 reply.result.unwrap_err().code,
21569 NodeFailureCode::UnsupportedCapability,
21570 );
21571 }
21572
21573 #[test]
21574 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21575 let shared = terminal_test_shared();
21576 let history = HistorySessionRecord {
21577 session_id: "fixture-history".to_owned(),
21578 title: None,
21579 cwd: None,
21580 model: None,
21581 message_count: 1,
21582 completed_turn_count: None,
21583 total_tokens: 0,
21584 messages: vec![gate4agent_types::HistoryMessageRecord {
21585 role: gate4agent_types::HistoryMessageRole::User,
21586 text: "bounded handoff".to_owned(),
21587 }],
21588 };
21589 let pack = NodeContextPack::export(
21590 ContextPackLineageReceipt {
21591 source_node_id: shared.node_id.clone(),
21592 source_session: terminal_address(1),
21593 source_provider: agent("claude"),
21594 },
21595 &history,
21596 )
21597 .unwrap();
21598 let context = shared
21599 .context_catalog
21600 .write()
21601 .unwrap_or_else(|poisoned| poisoned.into_inner())
21602 .insert(pack)
21603 .unwrap();
21604 let mut retained = record("codex", "context-retained-record");
21605 retained.context_id = Some(context.id.clone());
21606 retained.context = Some(context.clone());
21607 let record_id = retained.record_id.clone();
21608 shared.insert_record(retained).unwrap();
21609 assert_eq!(
21610 shared.forget_context_pack(&context.id).unwrap_err().code,
21611 NodeFailureCode::ContextPackBusy,
21612 );
21613 assert!(shared
21614 .context_catalog
21615 .read()
21616 .unwrap_or_else(|poisoned| poisoned.into_inner())
21617 .get(&context.id)
21618 .is_some());
21619 shared
21620 .session_records
21621 .lock()
21622 .unwrap_or_else(|poisoned| poisoned.into_inner())
21623 .records
21624 .remove(&record_id);
21625
21626 let mut spec = spawn_spec_fixture();
21627 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21628 value: context.id.clone(),
21629 };
21630 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21631 let receipt = resolved.receipt_with_materialization(
21632 shared.incarnation_id,
21633 terminal_address(2),
21634 None,
21635 None,
21636 Some(context.clone()),
21637 );
21638 assert!(receipt.context_binding_is_valid());
21639 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21640
21641 shared.forget_context_pack(&context.id).unwrap();
21642 assert!(shared
21643 .context_catalog
21644 .read()
21645 .unwrap_or_else(|poisoned| poisoned.into_inner())
21646 .get(&context.id)
21647 .is_none());
21648 assert!(shared
21649 .replay_spawn_spec(&spec, Instant::now())
21650 .unwrap()
21651 .is_none());
21652 }
21653
21654 #[test]
21655 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21656 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21657 let address = terminal_address(1);
21658 let selection = shared
21659 .select_environment_profile(
21660 address.session.instance_id,
21661 &agent("claude"),
21662 SessionMode::Pty,
21663 Some(&environment_profile),
21664 )
21665 .unwrap()
21666 .unwrap();
21667 shared.bind_session_with_policy(
21668 &address,
21669 ProviderRuntimePolicy::raw_pty(),
21670 Some(environment_profile.clone()),
21671 );
21672 selection.retain();
21673 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21674 assert!(matches!(
21675 runtime.remove_native_launch_profile(&native_id),
21676 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21677 ));
21678 assert!(shared.remove_binding(&address).is_some());
21679 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21680
21681 let mismatched = ResolvedEnvironmentProfileReceipt {
21682 profile_id: environment_profile.profile_id,
21683 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21684 "local-claude-r2",
21685 )
21686 .unwrap(),
21687 network_allowlist: None,
21688 browser_profile_id: None,
21689 };
21690 let error = match shared.select_environment_profile(
21691 AgentInstanceId(42),
21692 &agent("claude"),
21693 SessionMode::Pty,
21694 Some(&mismatched),
21695 ) {
21696 Ok(_) => panic!("mismatched environment profile revision was selected"),
21697 Err(error) => error,
21698 };
21699 assert_eq!(
21700 error.code,
21701 NodeFailureCode::EnvironmentProfileBindingMismatch,
21702 );
21703 assert!(!shared
21704 .session_bindings
21705 .lock()
21706 .unwrap_or_else(|poisoned| poisoned.into_inner())
21707 .contains_key(&AgentInstanceId(42)));
21708 }
21709
21710 #[test]
21711 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21712 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21713 let address = terminal_address(1);
21714 let selection = shared
21715 .select_environment_profile(
21716 address.session.instance_id,
21717 &agent("claude"),
21718 SessionMode::Pty,
21719 Some(&environment_profile),
21720 )
21721 .unwrap()
21722 .unwrap();
21723 let overlay = NativeInstanceLaunchOverlay::new(
21724 agent("claude"),
21725 TransportKind::Pty,
21726 vec![EnvMutation {
21727 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21728 value: Some(OsString::from("fixture-secret")),
21729 }],
21730 vec![
21731 OsString::from("--plugin-dir"),
21732 std::env::current_dir().unwrap().into_os_string(),
21733 ],
21734 )
21735 .unwrap();
21736 let instance_overlay = shared
21737 .install_prepared_launch_overlay(
21738 address.session.instance_id,
21739 PreparedNativeLaunchOverlay::Instance(overlay),
21740 )
21741 .unwrap()
21742 .unwrap();
21743 let bundle = ResolvedBundleReceipt {
21744 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21745 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21746 digest: crate::protocol::SpawnBundleDigest::new(format!(
21747 "sha256:{}",
21748 "0".repeat(64),
21749 ))
21750 .unwrap(),
21751 };
21752 shared.bind_session_with_materialization(
21753 &address,
21754 ProviderRuntimePolicy::raw_pty(),
21755 Some(environment_profile),
21756 Some(bundle),
21757 None,
21758 None,
21759 );
21760 selection.retain();
21761 instance_overlay.retain();
21762
21763 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21764 assert!(matches!(
21765 runtime.remove_native_launch_profile(&native_id),
21766 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21767 ));
21768 assert!(shared.remove_binding(&address).is_some());
21769 assert!(!shared
21770 .native_launch_profile_control
21771 .as_ref()
21772 .unwrap()
21773 .clear_native_instance_launch_overlay(address.session.instance_id));
21774 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21775 }
21776
21777 #[test]
21778 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21779 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21780 let address = SessionAddress {
21781 workspace_id: WorkspaceId::new("primary").unwrap(),
21782 session: SessionKey {
21783 instance_id: AgentInstanceId(201),
21784 generation: SessionGeneration(1),
21785 },
21786 };
21787 let error = match shared.prepare_session_materialization(
21788 &address,
21789 &agent("claude"),
21790 SessionMode::Pty,
21791 Some(&receipt),
21792 None,
21793 None,
21794 None,
21795 ) {
21796 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21797 Err(error) => error,
21798 };
21799 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21800 assert!(shared.handle.snapshot().sessions.is_empty());
21801 assert!(shared
21802 .session_bindings
21803 .lock()
21804 .unwrap_or_else(|poisoned| poisoned.into_inner())
21805 .is_empty());
21806 assert!(shared
21807 .materializations
21808 .lock()
21809 .unwrap_or_else(|poisoned| poisoned.into_inner())
21810 .is_empty());
21811 drop(shared);
21812 drop(runtime);
21813 std::fs::remove_dir_all(root).unwrap();
21814 }
21815
21816 #[test]
21817 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21818 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21819 let address = SessionAddress {
21820 workspace_id: WorkspaceId::new("primary").unwrap(),
21821 session: SessionKey {
21822 instance_id: AgentInstanceId(202),
21823 generation: SessionGeneration(1),
21824 },
21825 };
21826 let (overlay, materialization_guard) = shared
21827 .prepare_session_materialization(
21828 &address,
21829 &agent("claude"),
21830 SessionMode::Pty,
21831 Some(&receipt),
21832 None,
21833 None,
21834 None,
21835 )
21836 .unwrap()
21837 .unwrap();
21838 let materialization_id = materialization_guard.id().unwrap().clone();
21839 let materialization_root = shared
21840 .materializations
21841 .lock()
21842 .unwrap_or_else(|poisoned| poisoned.into_inner())
21843 .get(&materialization_id)
21844 .unwrap()
21845 .root()
21846 .to_path_buf();
21847 let selection = shared
21848 .select_environment_profile(
21849 address.session.instance_id,
21850 &agent("claude"),
21851 SessionMode::Pty,
21852 Some(&receipt),
21853 )
21854 .unwrap()
21855 .unwrap();
21856 assert!(shared
21857 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21858 .unwrap()
21859 .is_none());
21860 shared.bind_session_with_materialization(
21861 &address,
21862 ProviderRuntimePolicy::raw_pty(),
21863 Some(receipt),
21864 None,
21865 None,
21866 Some(materialization_id),
21867 );
21868 selection.retain();
21869 materialization_guard.retain();
21870
21871 assert!(materialization_root.is_dir());
21872 assert!(shared
21873 .session_bindings
21874 .lock()
21875 .unwrap_or_else(|poisoned| poisoned.into_inner())
21876 .contains_key(&address.session.instance_id));
21877
21878 let removed = shared.remove_binding(&address).unwrap();
21879 shared
21880 .cleanup_session_owned_materialization(&address, &removed)
21881 .unwrap();
21882 assert!(!materialization_root.exists());
21883 assert!(shared
21884 .materializations
21885 .lock()
21886 .unwrap_or_else(|poisoned| poisoned.into_inner())
21887 .is_empty());
21888 drop(shared);
21889 drop(runtime);
21890 std::fs::remove_dir_all(root).unwrap();
21891 }
21892
21893 #[tokio::test]
21894 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21895 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21896 let address = SessionAddress {
21897 workspace_id: WorkspaceId::new("primary").unwrap(),
21898 session: SessionKey {
21899 instance_id: AgentInstanceId(203),
21900 generation: SessionGeneration(1),
21901 },
21902 };
21903 let (overlay, materialization_guard) = shared
21904 .prepare_session_materialization(
21905 &address,
21906 &agent("claude"),
21907 SessionMode::Pty,
21908 Some(&receipt),
21909 None,
21910 None,
21911 None,
21912 )
21913 .unwrap()
21914 .unwrap();
21915 let materialization_id = materialization_guard.id().unwrap().clone();
21916 let materialization_root = shared
21917 .materializations
21918 .lock()
21919 .unwrap_or_else(|poisoned| poisoned.into_inner())
21920 .get(&materialization_id)
21921 .unwrap()
21922 .root()
21923 .to_path_buf();
21924 let selection = shared
21925 .select_environment_profile(
21926 address.session.instance_id,
21927 &agent("claude"),
21928 SessionMode::Pty,
21929 Some(&receipt),
21930 )
21931 .unwrap()
21932 .unwrap();
21933 assert!(shared
21934 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21935 .unwrap()
21936 .is_none());
21937 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21938 let record_id = shared
21939 .bind_spawn_session_with_materialization(
21940 &address,
21941 agent("claude"),
21942 SessionMode::Pty,
21943 policy,
21944 SpawnRecordPolicy::ProviderIdentityOnly,
21945 Some(receipt.clone()),
21946 None,
21947 None,
21948 Some(materialization_id.clone()),
21949 )
21950 .unwrap()
21951 .unwrap();
21952 selection.retain();
21953 materialization_guard.retain();
21954 assert_eq!(
21955 shared
21956 .materializations
21957 .lock()
21958 .unwrap_or_else(|poisoned| poisoned.into_inner())
21959 .get(&materialization_id)
21960 .unwrap()
21961 .owner(),
21962 &MaterializationOwner::Record {
21963 record_id: record_id.clone(),
21964 },
21965 );
21966
21967 let removed = shared.remove_binding(&address).unwrap();
21968 shared
21969 .cleanup_session_owned_materialization(&address, &removed)
21970 .unwrap();
21971 assert!(materialization_root.is_dir());
21972 assert!(shared
21973 .resolve_record_materialization(
21974 &record_id,
21975 &agent("claude"),
21976 SessionMode::Pty,
21977 Some(&receipt),
21978 None,
21979 None,
21980 )
21981 .unwrap()
21982 .is_some());
21983 {
21984 let mut records = shared
21985 .session_records
21986 .lock()
21987 .unwrap_or_else(|poisoned| poisoned.into_inner());
21988 let record = records.records.get_mut(&record_id).unwrap();
21989 record.active_session = None;
21990 record.state = ManagedSessionState::Unavailable;
21991 }
21992 shared.forget_session_record(&record_id).await.unwrap();
21993 assert!(!materialization_root.exists());
21994 assert!(shared
21995 .materializations
21996 .lock()
21997 .unwrap_or_else(|poisoned| poisoned.into_inner())
21998 .is_empty());
21999 drop(shared);
22000 drop(runtime);
22001 std::fs::remove_dir_all(root).unwrap();
22002 }
22003
22004 #[test]
22005 fn record_materialization_revalidation_failure_persists_recovery_required() {
22006 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22007 let state_path = root.join("node-state-v6.json");
22008 shared.state_path = Some(state_path.clone());
22009 let (_address, _record_id, materialization_id, materialization_root) =
22010 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22011 std::fs::write(
22012 materialization_root.join(".gate4agent-materialization-owner"),
22013 b"tampered",
22014 )
22015 .unwrap();
22016
22017 let error = match shared.resolve_record_materialization(
22018 &_record_id,
22019 &agent("claude"),
22020 SessionMode::Pty,
22021 Some(&receipt),
22022 None,
22023 None,
22024 ) {
22025 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22026 Err(error) => error,
22027 };
22028 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22029 assert_eq!(
22030 shared
22031 .materializations
22032 .lock()
22033 .unwrap_or_else(|poisoned| poisoned.into_inner())
22034 .get(&materialization_id)
22035 .unwrap()
22036 .state(),
22037 MaterializationState::RecoveryRequired,
22038 );
22039 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22040 assert_eq!(loaded.materializations.len(), 1);
22041 assert_eq!(
22042 loaded.materializations[0].state(),
22043 MaterializationState::RecoveryRequired,
22044 );
22045 drop(shared);
22046 drop(runtime);
22047 std::fs::remove_dir_all(root).unwrap();
22048 }
22049
22050 #[test]
22051 fn transient_record_materialization_resolution_failure_remains_ready() {
22052 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22053 let (_address, record_id, materialization_id, _materialization_root) =
22054 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22055 deny.store(true, Ordering::Release);
22056
22057 let error = match shared.resolve_record_materialization(
22058 &record_id,
22059 &agent("claude"),
22060 SessionMode::Pty,
22061 Some(&receipt),
22062 None,
22063 None,
22064 ) {
22065 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22066 Err(error) => error,
22067 };
22068 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22069 assert_eq!(
22070 shared
22071 .materializations
22072 .lock()
22073 .unwrap_or_else(|poisoned| poisoned.into_inner())
22074 .get(&materialization_id)
22075 .unwrap()
22076 .state(),
22077 MaterializationState::Ready,
22078 );
22079 drop(shared);
22080 drop(runtime);
22081 std::fs::remove_dir_all(root).unwrap();
22082 }
22083
22084 #[test]
22085 fn startup_missing_record_materialization_is_not_resumable() {
22086 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22087 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22088 shared
22089 .insert_record(ManagedSessionRecord {
22090 record_id: record_id.clone(),
22091 display_name: "missing materialization".to_owned(),
22092 provider: agent("claude"),
22093 mode: SessionMode::Pty,
22094 state: ManagedSessionState::Dormant,
22095 workspace_id: WorkspaceId::new("primary").unwrap(),
22096 canonical_root: opaque_windows_path(
22097 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22098 ),
22099 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22100 key: gate4agent_types::ProviderSessionKey::SessionId,
22101 id: "provider-session-missing-materialization".to_owned(),
22102 transcript_path: None,
22103 }),
22104 active_session: None,
22105 environment_profile: Some(receipt),
22106 bundle: None,
22107 context_id: None,
22108 context: None,
22109 exported_context: None,
22110 task_binding: None,
22111 created_at_unix_ms: 1,
22112 updated_at_unix_ms: 1,
22113 last_error: None,
22114 })
22115 .unwrap();
22116
22117 shared.reconcile_materializations();
22118 let record = shared.record(&record_id).unwrap();
22119 assert_eq!(record.state, ManagedSessionState::Unavailable);
22120 assert!(record.provider_session.is_none());
22121 assert_eq!(
22122 record.last_error.as_deref(),
22123 Some("environment-profile-unavailable"),
22124 );
22125 drop(shared);
22126 drop(runtime);
22127 std::fs::remove_dir_all(root).unwrap();
22128 }
22129
22130 #[tokio::test]
22131 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22132 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22133 let lease = install_managed_lease(&shared);
22134 let address = SessionAddress {
22135 workspace_id: WorkspaceId::new("primary").unwrap(),
22136 session: SessionKey {
22137 instance_id: AgentInstanceId(204),
22138 generation: SessionGeneration(1),
22139 },
22140 };
22141 let (overlay, materialization_guard) = shared
22142 .prepare_session_materialization(
22143 &address,
22144 &agent("claude"),
22145 SessionMode::Pty,
22146 Some(&receipt),
22147 None,
22148 None,
22149 Some(lease.lease_id.clone()),
22150 )
22151 .unwrap()
22152 .unwrap();
22153 let materialization_id = materialization_guard.id().unwrap().clone();
22154 let materialization_root = shared
22155 .materializations
22156 .lock()
22157 .unwrap_or_else(|poisoned| poisoned.into_inner())
22158 .get(&materialization_id)
22159 .unwrap()
22160 .root()
22161 .to_path_buf();
22162 let selection = shared
22163 .select_environment_profile(
22164 address.session.instance_id,
22165 &agent("claude"),
22166 SessionMode::Pty,
22167 Some(&receipt),
22168 )
22169 .unwrap()
22170 .unwrap();
22171 assert!(shared
22172 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22173 .unwrap()
22174 .is_none());
22175 shared.bind_session_with_materialization(
22176 &address,
22177 ProviderRuntimePolicy::raw_pty(),
22178 Some(receipt),
22179 None,
22180 None,
22181 Some(materialization_id),
22182 );
22183 selection.retain();
22184 materialization_guard.retain();
22185
22186 let blocked = shared
22187 .cleanup_managed_worktree(&lease.lease_id, false)
22188 .await
22189 .unwrap_err();
22190 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22191 assert!(materialization_root.is_dir());
22192 let removed = shared.remove_binding(&address).unwrap();
22193 shared
22194 .cleanup_session_owned_materialization(&address, &removed)
22195 .unwrap();
22196 assert!(!materialization_root.exists());
22197 drop(shared);
22198 drop(runtime);
22199 std::fs::remove_dir_all(root).unwrap();
22200 }
22201
22202 #[tokio::test]
22203 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22204 let git_root = temporary_workspace_root("raw-managed-inventory");
22205 let source = git_root.join("source");
22206 let allocation = git_root.join("allocation");
22207 std::fs::create_dir_all(&source).unwrap();
22208 std::fs::create_dir_all(&allocation).unwrap();
22209 run_test_git(&source, &["init"]);
22210 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22211 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22212 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22213 run_test_git(&source, &["add", "seed.txt"]);
22214 run_test_git(&source, &["commit", "-m", "seed"]);
22215 let profile = ManagedWorktreeProfile::new(
22216 WorktreeProfileId::new("default").unwrap(),
22217 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22218 &allocation,
22219 "gate4agent",
22220 "HEAD",
22221 ManagedWorktreeRetention::RemoveWhenReleased,
22222 )
22223 .unwrap();
22224 let workspace = WorkspaceConfig::new(
22225 WorkspaceId::new("primary").unwrap(),
22226 &source,
22227 )
22228 .unwrap()
22229 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22230 .with_managed_worktree_profile(profile.clone())
22231 .unwrap();
22232 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22233 materialization_test_shared_with_workspace(false, workspace);
22234 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22235 let source_text = source.to_string_lossy().into_owned();
22236 let target_text = target.to_string_lossy().into_owned();
22237 let created = create_git_worktree(
22238 &source_text,
22239 &target_text,
22240 "gate4agent/mw-raw-inventory",
22241 Some("HEAD"),
22242 )
22243 .await
22244 .unwrap();
22245 let lease = ManagedWorktreeLeaseRecord {
22246 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22247 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22248 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22249 profile_id: profile.profile_id().clone(),
22250 profile_revision: profile.revision().clone(),
22251 target_root: created.path.clone(),
22252 branch: "gate4agent/mw-raw-inventory".to_owned(),
22253 base_commit: created.head.clone(),
22254 expected_head: Some(created.head.clone()),
22255 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22256 state: ManagedWorktreeLeaseState::Ready,
22257 session_holders: Vec::new(),
22258 record_holders: Vec::new(),
22259 cleanup_failure: None,
22260 created_at_unix_ms: 1,
22261 updated_at_unix_ms: 1,
22262 };
22263 shared
22264 .workspaces
22265 .write()
22266 .unwrap_or_else(|poisoned| poisoned.into_inner())
22267 .insert(lease.workspace_id.clone(), created.path.clone());
22268 *shared
22269 .managed_worktrees
22270 .lock()
22271 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22272 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22273 let address = SessionAddress {
22274 workspace_id: lease.workspace_id.clone(),
22275 session: SessionKey {
22276 instance_id: AgentInstanceId(205),
22277 generation: SessionGeneration(1),
22278 },
22279 };
22280 let (overlay, materialization_guard) = shared
22281 .prepare_session_materialization(
22282 &address,
22283 &agent("claude"),
22284 SessionMode::Pty,
22285 Some(&receipt),
22286 None,
22287 None,
22288 Some(lease.lease_id.clone()),
22289 )
22290 .unwrap()
22291 .unwrap();
22292 let materialization_id = materialization_guard.id().unwrap().clone();
22293 let materialization_root = shared
22294 .materializations
22295 .lock()
22296 .unwrap_or_else(|poisoned| poisoned.into_inner())
22297 .get(&materialization_id)
22298 .unwrap()
22299 .root()
22300 .to_path_buf();
22301 let selection = shared
22302 .select_environment_profile(
22303 address.session.instance_id,
22304 &agent("claude"),
22305 SessionMode::Pty,
22306 Some(&receipt),
22307 )
22308 .unwrap()
22309 .unwrap();
22310 assert!(shared
22311 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22312 .unwrap()
22313 .is_none());
22314 let record_id = shared
22315 .bind_spawn_session_with_materialization(
22316 &address,
22317 agent("claude"),
22318 SessionMode::Pty,
22319 ProviderRuntimePolicy::raw_pty(),
22320 SpawnRecordPolicy::Always,
22321 Some(receipt.clone()),
22322 None,
22323 None,
22324 Some(materialization_id.clone()),
22325 )
22326 .unwrap()
22327 .unwrap();
22328 selection.retain();
22329 materialization_guard.retain();
22330 let record = shared.record(&record_id).unwrap();
22331 assert_eq!(record.state, ManagedSessionState::Live);
22332 assert!(record.provider_session.is_none());
22333 assert!(record.environment_profile.is_none());
22334 assert!(record.bundle.is_none());
22335 assert!(record.context_id.is_none());
22336 assert!(record.context.is_none());
22337 assert_eq!(record.active_session.as_ref(), Some(&address));
22338 assert_eq!(
22339 shared
22340 .materializations
22341 .lock()
22342 .unwrap_or_else(|poisoned| poisoned.into_inner())
22343 .get(&materialization_id)
22344 .unwrap()
22345 .owner(),
22346 &MaterializationOwner::Session {
22347 incarnation_id: shared.incarnation_id,
22348 instance_id: address.session.instance_id,
22349 generation: address.session.generation,
22350 },
22351 );
22352 {
22353 let mut bindings = shared
22354 .session_bindings
22355 .lock()
22356 .unwrap_or_else(|poisoned| poisoned.into_inner());
22357 bindings
22358 .get_mut(&address.session.instance_id)
22359 .unwrap()
22360 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22361 }
22362 let lease_snapshot = shared
22363 .managed_worktrees
22364 .lock()
22365 .unwrap_or_else(|poisoned| poisoned.into_inner())
22366 .bind_session(
22367 &lease.lease_id,
22368 ManagedWorktreeSessionHolder {
22369 incarnation_id: shared.incarnation_id,
22370 instance_id: address.session.instance_id,
22371 generation: address.session.generation,
22372 },
22373 None,
22374 unix_time_ms(),
22375 )
22376 .unwrap();
22377 assert_eq!(lease_snapshot.active_session_count, 1);
22378 assert_eq!(lease_snapshot.managed_record_count, 0);
22379 shared.persist_state().unwrap();
22380 assert!(shared
22381 .managed_worktrees
22382 .lock()
22383 .unwrap_or_else(|poisoned| poisoned.into_inner())
22384 .get(&lease.lease_id)
22385 .unwrap()
22386 .record_holders
22387 .is_empty());
22388 shared
22389 .handle
22390 .dispatch(shared.prepare_command(ControlCommand::Register {
22391 instance_id: address.session.instance_id,
22392 agent_id: agent("claude"),
22393 transport: TransportKind::Pty,
22394 }))
22395 .unwrap();
22396 runtime.tick().await;
22397 shared.reconcile_managed_record(
22398 &record_id,
22399 &address,
22400 &ControlEvent {
22401 sequence: 1,
22402 command_id: None,
22403 instance_id: address.session.instance_id,
22404 generation: address.session.generation,
22405 event: ControlEventKind::Removed,
22406 },
22407 false,
22408 false,
22409 );
22410 let remove = shared.remove_session(&address);
22411 let drive_runtime = async {
22412 runtime.tick().await;
22413 };
22414 let (removed, ()) = tokio::join!(remove, drive_runtime);
22415 removed.unwrap();
22416
22417 assert!(!materialization_root.exists());
22418 assert!(!Path::new(&created.path).exists());
22419 assert_eq!(
22420 shared
22421 .managed_worktrees
22422 .lock()
22423 .unwrap_or_else(|poisoned| poisoned.into_inner())
22424 .get(&lease.lease_id)
22425 .unwrap()
22426 .state,
22427 ManagedWorktreeLeaseState::Removed,
22428 );
22429 let renamed = shared
22430 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22431 .unwrap();
22432 assert_eq!(renamed.record_id, record_id);
22433 assert_eq!(renamed.display_name, "raw managed inventory");
22434 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22435 assert!(renamed.active_session.is_none());
22436
22437 drop(shared);
22438 drop(runtime);
22439 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22440 std::fs::remove_dir_all(git_root).unwrap();
22441 }
22442
22443 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22444 let root = std::env::current_dir().unwrap()
22445 .join("managed-test-target")
22446 .to_string_lossy()
22447 .into_owned();
22448 let lease = ManagedWorktreeLeaseRecord {
22449 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22450 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22451 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22452 profile_id: WorktreeProfileId::new("default").unwrap(),
22453 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22454 target_root: root.clone(),
22455 branch: "gate4agent/mw-test".to_owned(),
22456 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22457 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22458 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22459 state: ManagedWorktreeLeaseState::Ready,
22460 session_holders: Vec::new(),
22461 record_holders: Vec::new(),
22462 cleanup_failure: None,
22463 created_at_unix_ms: 1,
22464 updated_at_unix_ms: 1,
22465 };
22466 shared.workspaces.write()
22467 .unwrap_or_else(|poisoned| poisoned.into_inner())
22468 .insert(lease.workspace_id.clone(), root);
22469 *shared.managed_worktrees.lock()
22470 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22471 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22472 lease
22473 }
22474
22475 #[cfg(feature = "fixture")]
22476 #[test]
22477 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22478 let mut shared = terminal_test_shared();
22479 shared.fixture_semantic_hook_policy = true;
22480 let lease = install_managed_lease(&shared);
22481 let address = SessionAddress {
22482 workspace_id: lease.workspace_id.clone(),
22483 session: SessionKey {
22484 instance_id: AgentInstanceId(206),
22485 generation: SessionGeneration(1),
22486 },
22487 };
22488 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22489 let effective_runtime_policy =
22490 shared.effective_spawn_runtime_policy(pre_admission_policy);
22491 assert_ne!(effective_runtime_policy, pre_admission_policy);
22492 assert!(effective_runtime_policy.provider_session_identity);
22493
22494 let record_id = shared
22495 .bind_spawn_session(
22496 &address,
22497 agent("claude"),
22498 SessionMode::Pty,
22499 effective_runtime_policy,
22500 SpawnRecordPolicy::Always,
22501 None,
22502 )
22503 .unwrap()
22504 .unwrap();
22505 assert_eq!(
22506 shared.record(&record_id).unwrap().state,
22507 ManagedSessionState::IdentityPending,
22508 );
22509 let (record_holder, raw_inventory_record_id) = {
22510 let mut bindings = shared
22511 .session_bindings
22512 .lock()
22513 .unwrap_or_else(|poisoned| poisoned.into_inner());
22514 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22515 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22516 managed_spawn_record_ownership(binding, effective_runtime_policy)
22517 };
22518 assert_eq!(record_holder.as_ref(), Some(&record_id));
22519 assert!(raw_inventory_record_id.is_none());
22520 let snapshot = shared
22521 .managed_worktrees
22522 .lock()
22523 .unwrap_or_else(|poisoned| poisoned.into_inner())
22524 .bind_session(
22525 &lease.lease_id,
22526 ManagedWorktreeSessionHolder {
22527 incarnation_id: shared.incarnation_id,
22528 instance_id: address.session.instance_id,
22529 generation: address.session.generation,
22530 },
22531 record_holder,
22532 unix_time_ms(),
22533 )
22534 .unwrap();
22535 assert_eq!(snapshot.managed_record_count, 1);
22536 assert_eq!(
22537 shared
22538 .managed_worktrees
22539 .lock()
22540 .unwrap_or_else(|poisoned| poisoned.into_inner())
22541 .get(&lease.lease_id)
22542 .unwrap()
22543 .record_holders,
22544 vec![record_id.clone()],
22545 );
22546
22547 let identity = ProviderSessionIdentity {
22548 key: ProviderSessionKey::SessionId,
22549 id: "monitoring-fixture-managed-session".to_owned(),
22550 transcript_path: None,
22551 };
22552 shared.publish_control(ControlEvent {
22553 sequence: 1,
22554 command_id: None,
22555 instance_id: address.session.instance_id,
22556 generation: address.session.generation,
22557 event: ControlEventKind::ProviderEvent {
22558 sequence: 1,
22559 source: gate4agent_types::ProviderSource {
22560 family: AdapterFamily::Hook,
22561 binding: AdapterBinding::new(
22562 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22563 "fixture/v1",
22564 gate4agent_types::AdapterVerification::SyntheticFixture,
22565 )
22566 .unwrap(),
22567 },
22568 source_sequence: 1,
22569 event: ProviderEvent::SessionIdentityObserved {
22570 identity: identity.clone(),
22571 },
22572 },
22573 });
22574
22575 let live = shared.record(&record_id).unwrap();
22576 assert_eq!(live.state, ManagedSessionState::Live);
22577 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22578 assert_eq!(live.active_session.as_ref(), Some(&address));
22579 assert_eq!(
22580 shared
22581 .managed_worktrees
22582 .lock()
22583 .unwrap_or_else(|poisoned| poisoned.into_inner())
22584 .get(&lease.lease_id)
22585 .unwrap()
22586 .record_holders,
22587 vec![record_id],
22588 );
22589 }
22590
22591 fn managed_v2_test_shared(
22592 allocation_root: &Path,
22593 state_path: Option<PathBuf>,
22594 profile_revision: &str,
22595 ) -> NodeShared {
22596 let profile = ManagedWorktreeProfile::new(
22597 WorktreeProfileId::new("default").unwrap(),
22598 WorktreeProfileRevision::new(profile_revision).unwrap(),
22599 allocation_root,
22600 "gate4agent",
22601 "HEAD",
22602 ManagedWorktreeRetention::RemoveWhenReleased,
22603 )
22604 .unwrap();
22605 let workspace = WorkspaceConfig::new(
22606 WorkspaceId::new("primary").unwrap(),
22607 std::env::current_dir().unwrap(),
22608 )
22609 .unwrap()
22610 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22611 .with_managed_worktree_profile(profile)
22612 .unwrap();
22613 let catalog = active_registry().unwrap();
22614 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22615 let mut shared = NodeShared::new(
22616 handle,
22617 "fixture-token".to_owned(),
22618 NodeId::new("node-terminal-test").unwrap(),
22619 vec![workspace],
22620 vec![agent("claude")],
22621 );
22622 shared.state_path = state_path;
22623 shared
22624 }
22625
22626 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22627 let mut spawn_spec = spawn_spec_fixture();
22628 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22629 ManagedWorktreeSpawnRequestV2 {
22630 spawn_spec,
22631 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22632 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22633 }
22634 }
22635
22636 #[test]
22637 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22638 let shared = terminal_test_shared();
22639 let mut lease = install_managed_lease(&shared);
22640 let session = SessionAddress {
22641 workspace_id: lease.workspace_id.clone(),
22642 session: SessionKey {
22643 instance_id: AgentInstanceId(73),
22644 generation: SessionGeneration(4),
22645 },
22646 };
22647 lease.state = ManagedWorktreeLeaseState::InUse;
22648 lease.session_holders.push(ManagedWorktreeSessionHolder {
22649 incarnation_id: shared.incarnation_id,
22650 instance_id: session.session.instance_id,
22651 generation: session.session.generation,
22652 });
22653 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22654 assert!(resolved.target.worktree_id.is_none());
22655
22656 let receipt = managed_spawn_receipt(
22657 &resolved,
22658 shared.incarnation_id,
22659 session.clone(),
22660 lease.snapshot(),
22661 None,
22662 None,
22663 None,
22664 );
22665 assert!(resolved.target.worktree_id.is_none());
22666 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22667 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22668 assert_eq!(receipt.spawn.session, session);
22669 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22670 let encoded = serde_json::to_vec(&receipt).unwrap();
22671 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22672 assert_eq!(decoded, receipt);
22673 }
22674
22675 #[tokio::test]
22676 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22677 let root = temporary_workspace_root("managed-v2-revision");
22678 let allocation = root.join("allocation");
22679 std::fs::create_dir_all(&allocation).unwrap();
22680 let shared = managed_v2_test_shared(
22681 &allocation,
22682 Some(root.join("node-state.json")),
22683 "v1",
22684 );
22685 let mut request = managed_v2_request("managed-v2-revision");
22686 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22687
22688 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22689
22690 assert_eq!(
22691 failure.code,
22692 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22693 );
22694 assert!(shared
22695 .managed_worktrees
22696 .lock()
22697 .unwrap_or_else(|poisoned| poisoned.into_inner())
22698 .records()
22699 .is_empty());
22700 assert!(shared
22701 .managed_spawn_replays
22702 .lock()
22703 .unwrap_or_else(|poisoned| poisoned.into_inner())
22704 .is_empty());
22705 std::fs::remove_dir_all(root).unwrap();
22706 }
22707
22708 #[test]
22709 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22710 let root = temporary_workspace_root("managed-v2-concurrent");
22711 let allocation = root.join("allocation");
22712 std::fs::create_dir_all(&allocation).unwrap();
22713 let shared = Arc::new(managed_v2_test_shared(
22714 &allocation,
22715 Some(root.join("node-state.json")),
22716 "v1",
22717 ));
22718 let request = managed_v2_request("managed-v2-concurrent");
22719 let barrier = Arc::new(std::sync::Barrier::new(3));
22720 let mut joins = Vec::new();
22721 for _ in 0..2 {
22722 let shared = Arc::clone(&shared);
22723 let request = request.clone();
22724 let barrier = Arc::clone(&barrier);
22725 joins.push(std::thread::spawn(move || {
22726 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22727 barrier.wait();
22728 shared.acquire_durable_managed_spawn_v2(&request, digest)
22729 }));
22730 }
22731 barrier.wait();
22732 let results = joins
22733 .into_iter()
22734 .map(|join| join.join().unwrap())
22735 .collect::<Vec<_>>();
22736 assert_eq!(
22737 results
22738 .iter()
22739 .filter(|result| {
22740 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22741 })
22742 .count(),
22743 1,
22744 );
22745 assert_eq!(
22746 results
22747 .iter()
22748 .filter(|result| {
22749 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22750 })
22751 .count(),
22752 1,
22753 );
22754 assert_eq!(
22755 shared
22756 .managed_spawn_replays
22757 .lock()
22758 .unwrap_or_else(|poisoned| poisoned.into_inner())
22759 .len(),
22760 1,
22761 );
22762 assert!(shared
22763 .managed_worktrees
22764 .lock()
22765 .unwrap_or_else(|poisoned| poisoned.into_inner())
22766 .records()
22767 .is_empty());
22768 drop(shared);
22769 std::fs::remove_dir_all(root).unwrap();
22770 }
22771
22772 #[tokio::test]
22773 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22774 let root = temporary_workspace_root("managed-v2-linked-pending");
22775 let allocation = root.join("allocation");
22776 std::fs::create_dir_all(&allocation).unwrap();
22777 let state_path = root.join("node-state.json");
22778 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22779 let lease = install_managed_lease(&shared);
22780 let request = managed_v2_request("managed-v2-linked-pending");
22781 assert!(matches!(
22782 shared
22783 .acquire_durable_managed_spawn_v2(
22784 &request,
22785 managed_spawn_request_digest_v2(&request).unwrap(),
22786 )
22787 .unwrap(),
22788 DurableManagedSpawnReplayDecision::Reserved,
22789 ));
22790 {
22791 let _transaction = shared
22792 .state_transaction
22793 .lock()
22794 .unwrap_or_else(|poisoned| poisoned.into_inner());
22795 shared
22796 .link_durable_managed_spawn_v2_lease_locked(
22797 &request.spawn_spec.idempotency_key,
22798 &lease.lease_id,
22799 )
22800 .unwrap();
22801 shared.persist_state_locked().unwrap();
22802 }
22803 drop(shared);
22804
22805 let mut loaded = session_registry::load(
22806 Some(&state_path),
22807 &NodeId::new("node-terminal-test").unwrap(),
22808 )
22809 .unwrap();
22810 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22811 *reopened
22812 .managed_worktrees
22813 .lock()
22814 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22815 ManagedWorktreeRegistry::from_records(
22816 std::mem::take(&mut loaded.managed_worktrees),
22817 std::mem::take(&mut loaded.managed_worktree_tombstones),
22818 )
22819 .unwrap();
22820 *reopened
22821 .managed_spawn_replays
22822 .lock()
22823 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22824 .managed_spawn_replays
22825 .into_iter()
22826 .map(|record| (record.idempotency_key.clone(), record))
22827 .collect();
22828 let lease_count = reopened
22829 .managed_worktrees
22830 .lock()
22831 .unwrap_or_else(|poisoned| poisoned.into_inner())
22832 .records()
22833 .len();
22834
22835 let failure = reopened
22836 .spawn_managed_worktree_v2(request)
22837 .await
22838 .unwrap_err();
22839
22840 assert_eq!(
22841 failure.code,
22842 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22843 );
22844 assert_eq!(
22845 reopened
22846 .managed_worktrees
22847 .lock()
22848 .unwrap_or_else(|poisoned| poisoned.into_inner())
22849 .records()
22850 .len(),
22851 lease_count,
22852 );
22853 std::fs::remove_dir_all(root).unwrap();
22854 }
22855
22856 #[tokio::test]
22857 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22858 let root = temporary_workspace_root("managed-v2-reopen");
22859 let allocation = root.join("allocation");
22860 std::fs::create_dir_all(&allocation).unwrap();
22861 let state_path = root.join("node-state.json");
22862 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22863 let lease = install_managed_lease(&shared);
22864 let request = managed_v2_request("managed-v2-reopen");
22865 let session = SessionAddress {
22866 workspace_id: lease.workspace_id.clone(),
22867 session: SessionKey {
22868 instance_id: AgentInstanceId(91),
22869 generation: SessionGeneration(1),
22870 },
22871 };
22872 let snapshot = {
22873 let mut registry = shared
22874 .managed_worktrees
22875 .lock()
22876 .unwrap_or_else(|poisoned| poisoned.into_inner());
22877 let current = registry.get_mut(&lease.lease_id).unwrap();
22878 current.state = ManagedWorktreeLeaseState::InUse;
22879 current.session_holders.push(ManagedWorktreeSessionHolder {
22880 incarnation_id: shared.incarnation_id,
22881 instance_id: session.session.instance_id,
22882 generation: session.session.generation,
22883 });
22884 current.snapshot()
22885 };
22886 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22887 let receipt = managed_spawn_receipt(
22888 &resolved,
22889 shared.incarnation_id,
22890 session,
22891 snapshot,
22892 None,
22893 None,
22894 None,
22895 );
22896 assert!(matches!(
22897 shared
22898 .acquire_durable_managed_spawn_v2(
22899 &request,
22900 managed_spawn_request_digest_v2(&request).unwrap(),
22901 )
22902 .unwrap(),
22903 DurableManagedSpawnReplayDecision::Reserved,
22904 ));
22905 {
22906 let _transaction = shared
22907 .state_transaction
22908 .lock()
22909 .unwrap_or_else(|poisoned| poisoned.into_inner());
22910 shared
22911 .link_durable_managed_spawn_v2_lease_locked(
22912 &request.spawn_spec.idempotency_key,
22913 &lease.lease_id,
22914 )
22915 .unwrap();
22916 shared.persist_state_locked().unwrap();
22917 }
22918 shared
22919 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22920 .unwrap();
22921 let legacy_same_key = ManagedWorktreeSpawnRequest {
22922 spawn_spec: request.spawn_spec.clone(),
22923 worktree_profile_id: request.worktree_profile_id.clone(),
22924 };
22925 shared.remember_managed_spawn_attempt(
22926 Some(&request.spawn_spec.idempotency_key),
22927 legacy_same_key.clone(),
22928 Ok(receipt.clone()),
22929 Instant::now(),
22930 );
22931 assert!(shared
22932 .replay_managed_spawn(&legacy_same_key, Instant::now())
22933 .unwrap()
22934 .is_none());
22935 drop(shared);
22936
22937 let mut loaded = session_registry::load(
22938 Some(&state_path),
22939 &NodeId::new("node-terminal-test").unwrap(),
22940 )
22941 .unwrap();
22942 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22943 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22944 *reopened
22945 .managed_worktrees
22946 .lock()
22947 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22948 ManagedWorktreeRegistry::from_records(
22949 std::mem::take(&mut loaded.managed_worktrees),
22950 std::mem::take(&mut loaded.managed_worktree_tombstones),
22951 )
22952 .unwrap();
22953 *reopened
22954 .managed_spawn_replays
22955 .lock()
22956 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22957 .managed_spawn_replays
22958 .into_iter()
22959 .map(|record| (record.idempotency_key.clone(), record))
22960 .collect();
22961 let lease_count_before = reopened
22962 .managed_worktrees
22963 .lock()
22964 .unwrap_or_else(|poisoned| poisoned.into_inner())
22965 .records()
22966 .len();
22967
22968 assert_eq!(
22969 reopened
22970 .spawn_managed_worktree_v2(request.clone())
22971 .await
22972 .unwrap(),
22973 receipt,
22974 );
22975 assert_eq!(
22976 reopened
22977 .managed_worktrees
22978 .lock()
22979 .unwrap_or_else(|poisoned| poisoned.into_inner())
22980 .records()
22981 .len(),
22982 lease_count_before,
22983 );
22984 let mut changed = request;
22985 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22986 let failure = reopened
22987 .spawn_managed_worktree_v2(changed)
22988 .await
22989 .unwrap_err();
22990 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22991 assert_eq!(
22992 reopened
22993 .managed_worktrees
22994 .lock()
22995 .unwrap_or_else(|poisoned| poisoned.into_inner())
22996 .records()
22997 .len(),
22998 lease_count_before,
22999 );
23000 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
23001 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
23002 let failure = reopened
23003 .spawn_managed_worktree_v2(new_key)
23004 .await
23005 .unwrap_err();
23006 assert_eq!(
23007 failure.code,
23008 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23009 );
23010 assert_eq!(
23011 reopened
23012 .managed_worktrees
23013 .lock()
23014 .unwrap_or_else(|poisoned| poisoned.into_inner())
23015 .records()
23016 .len(),
23017 lease_count_before,
23018 );
23019 assert_eq!(
23020 reopened
23021 .managed_spawn_replays
23022 .lock()
23023 .unwrap_or_else(|poisoned| poisoned.into_inner())
23024 .len(),
23025 1,
23026 );
23027 std::fs::remove_dir_all(root).unwrap();
23028 }
23029
23030 #[test]
23031 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23032 let shared = terminal_test_shared();
23033 let request = ManagedWorktreeSpawnRequest {
23034 spawn_spec: spawn_spec_fixture(),
23035 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23036 };
23037 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23038 shared.remember_managed_spawn(
23039 request.clone(),
23040 Err(expected.clone()),
23041 Instant::now(),
23042 );
23043
23044 assert_eq!(
23045 shared
23046 .replay_managed_spawn(&request, Instant::now())
23047 .unwrap()
23048 .unwrap(),
23049 Err(expected),
23050 );
23051 assert!(shared
23052 .managed_spawn_replays
23053 .lock()
23054 .unwrap_or_else(|poisoned| poisoned.into_inner())
23055 .is_empty());
23056 }
23057
23058 #[tokio::test]
23059 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23060 let shared = terminal_test_shared();
23061 let lease = install_managed_lease(&shared);
23062 let raw = shared.spawn_session(
23063 lease.workspace_id.clone(),
23064 agent("claude"),
23065 SessionMode::Pty,
23066 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23067 None,
23068 ).await.unwrap_err();
23069 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23070 let mut spec = spawn_spec_fixture();
23071 spec.target.workspace_id = lease.workspace_id.clone();
23072 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23073 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23074 let mut selected = spawn_spec_fixture();
23075 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23076 selected.target.worktree_id = Some(lease.workspace_id);
23077 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23078 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23079 assert!(shared.handle.snapshot().sessions.is_empty());
23080 }
23081
23082 #[test]
23083 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23084 let shared = terminal_test_shared();
23085 let lease = install_managed_lease(&shared);
23086 let snapshot = snapshot_for_wire(
23087 &shared, true, true, true, false, true, true, false, false, true,
23088 );
23089 assert!(snapshot.managed_worktrees.is_empty());
23090 assert!(snapshot.workspaces.iter().all(|workspace| {
23091 workspace.managed_worktree_profiles.is_none()
23092 }));
23093 let mut reply = ResponseEnvelope {
23094 request_id: 1,
23095 result: Ok(NodeResponse::Resync {
23096 event_sequence: 1,
23097 oldest_available_sequence: 1,
23098 snapshot: shared.snapshot(),
23099 events: vec![NodeEventEnvelope {
23100 sequence: 1,
23101 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23102 }],
23103 }),
23104 };
23105 project_response_without_managed_worktrees(&mut reply);
23106 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23107 panic!("projection changed the response kind");
23108 };
23109 assert!(snapshot.managed_worktrees.is_empty());
23110 assert!(events.is_empty());
23111 let mut git = git_snapshot_for_parser();
23112 git.branch = Some(lease.branch.clone());
23113 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23114 lease_id: lease.lease_id.clone(),
23115 source_workspace_id: lease.source_workspace_id.clone(),
23116 branch: lease.branch.clone(),
23117 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23118 active_session_count: 1,
23119 managed_record_count: 0,
23120 });
23121 let mut inspection_reply = ResponseEnvelope {
23122 request_id: 2,
23123 result: Ok(NodeResponse::WorkspaceInspected {
23124 inspection: WorkspaceInspection {
23125 workspace_id: lease.workspace_id.clone(),
23126 entries: Vec::new(),
23127 tree_truncated: false,
23128 git,
23129 truncation: None,
23130 },
23131 }),
23132 };
23133 project_response_without_managed_worktrees(&mut inspection_reply);
23134 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23135 panic!("projection changed the inspection response kind");
23136 };
23137 assert!(inspection.git.managed_worktree.is_none());
23138 let failure = managed_git_worktree_failure(GitWorktreeError {
23139 kind: GitWorktreeErrorKind::Failed,
23140 message: r"git failed at C:\private\managed-target".to_owned(),
23141 });
23142 assert!(!failure.message.contains("private"));
23143 assert!(!failure.message.contains("managed-target"));
23144 }
23145
23146 #[tokio::test]
23147 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23148 let root = temporary_workspace_root("managed-restart");
23149 let source = root.join("source");
23150 let allocation = root.join("allocation");
23151 std::fs::create_dir_all(&source).unwrap();
23152 std::fs::create_dir_all(&allocation).unwrap();
23153 run_test_git(&source, &["init"]);
23154 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23155 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23156 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23157 run_test_git(&source, &["add", "seed.txt"]);
23158 run_test_git(&source, &["commit", "-m", "seed"]);
23159 let profile = ManagedWorktreeProfile::new(
23160 WorktreeProfileId::new("default").unwrap(),
23161 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23162 &allocation,
23163 "gate4agent",
23164 "HEAD",
23165 ManagedWorktreeRetention::Retain,
23166 ).unwrap();
23167 let workspace = WorkspaceConfig::new(
23168 WorkspaceId::new("primary").unwrap(),
23169 &source,
23170 ).unwrap()
23171 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23172 .with_managed_worktree_profile(profile.clone()).unwrap();
23173 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23174 let source_text = workspace.canonical_root().to_owned();
23175 let target_text = target.to_string_lossy().into_owned();
23176 let created = create_git_worktree(
23177 &source_text,
23178 &target_text,
23179 "gate4agent/mw-restart",
23180 Some("HEAD"),
23181 ).await.unwrap();
23182 let lease = ManagedWorktreeLeaseRecord {
23183 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23184 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23185 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23186 profile_id: profile.profile_id().clone(),
23187 profile_revision: profile.revision().clone(),
23188 target_root: created.path.clone(),
23189 branch: "gate4agent/mw-restart".to_owned(),
23190 base_commit: created.head.clone(),
23191 expected_head: None,
23192 retention: ManagedWorktreeRetention::Retain,
23193 state: ManagedWorktreeLeaseState::Allocating,
23194 session_holders: Vec::new(),
23195 record_holders: Vec::new(),
23196 cleanup_failure: None,
23197 created_at_unix_ms: 1,
23198 updated_at_unix_ms: 1,
23199 };
23200 let catalog = active_registry().unwrap();
23201 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23202 let shared = NodeShared::new_with_incarnation(
23203 handle,
23204 "fixture-token".to_owned(),
23205 NodeId::new("node-managed-restart").unwrap(),
23206 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23207 vec![workspace],
23208 vec![agent("claude")],
23209 ProviderRuntimeStatuses::default(),
23210 None,
23211 Vec::new(),
23212 Vec::new(),
23213 SpawnProfileRegistry::default(),
23214 None,
23215 None,
23216 None,
23217 None,
23218 Vec::new(),
23219 vec![lease.clone()],
23220 Vec::new(),
23221 Vec::new(),
23222 None,
23223 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23224 );
23225 shared.reconcile_managed_worktrees().await;
23226 let retained = shared.managed_worktrees.lock()
23227 .unwrap_or_else(|poisoned| poisoned.into_inner())
23228 .get(&lease.lease_id).unwrap().clone();
23229 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23230 assert!(shared.workspaces.read()
23231 .unwrap_or_else(|poisoned| poisoned.into_inner())
23232 .contains_key(&lease.workspace_id));
23233 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23234 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23235 assert!(!Path::new(&created.path).exists());
23236 std::fs::remove_dir_all(root).unwrap();
23237 }
23238
23239 fn run_test_git(root: &Path, arguments: &[&str]) {
23240 let output = std::process::Command::new("git")
23241 .arg("-C").arg(root).args(arguments).output().unwrap();
23242 assert!(
23243 output.status.success(),
23244 "git {:?} failed: {}",
23245 arguments,
23246 String::from_utf8_lossy(&output.stderr),
23247 );
23248 }
23249
23250 #[tokio::test]
23251 async fn spawn_spec_validation_fails_before_session_mutation() {
23252 let shared = terminal_test_shared();
23253 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23254 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23255 }));
23256 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23257 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23258 }));
23259 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23260 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23261 }));
23262
23263 let mut target_mismatch = spawn_spec_fixture();
23264 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23265 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23266 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23267
23268 let mut unknown_profile = spawn_spec_fixture();
23269 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23270 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23271 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23272 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23273
23274 let mut revision_mismatch = spawn_spec_fixture();
23275 revision_mismatch.idempotency_key =
23276 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23277 revision_mismatch.expected_profile_revision =
23278 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23279 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23280 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23281
23282 let mut unsupported_materializer = spawn_spec_fixture();
23283 unsupported_materializer.idempotency_key =
23284 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23285 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23286 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23287 };
23288 let failure = shared
23289 .spawn_from_spec(unsupported_materializer)
23290 .await
23291 .unwrap_err();
23292 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23293
23294 let failure = shared
23295 .spawn_session_with_deadline(
23296 WorkspaceId::new("primary").unwrap(),
23297 agent("claude"),
23298 SessionMode::Pty,
23299 gate4agent_types::TerminalSize {
23300 rows: 24,
23301 columns: 80,
23302 },
23303 None,
23304 None,
23305 None,
23306 None,
23307 None,
23308 None,
23309 SpawnRecordPolicy::ProviderIdentityOnly,
23310 Some(Instant::now()),
23311 &[],
23312 None,
23313 ApprovalLevel::default(),
23314 None,
23315 None,
23316 )
23317 .await
23318 .unwrap_err();
23319 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23320 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23321 assert!(shared.handle.snapshot().sessions.is_empty());
23322 }
23323
23324 #[test]
23325 fn worktree_selection_requires_its_separate_negotiated_capability() {
23326 let mut spec = spawn_spec_fixture();
23327 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23328 let request = NodeRequest::SpawnSpec { spec };
23329 let spawn_spec_capability =
23330 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23331 assert!(request_uses_unnegotiated_capability(
23332 &request,
23333 &[spawn_spec_capability.clone()],
23334 ));
23335 let worktree_capability =
23336 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23337 assert!(request_uses_unnegotiated_capability(
23338 &request,
23339 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23340 ));
23341 let profile_revision_capability =
23342 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23343 assert!(request_uses_unnegotiated_capability(
23344 &request,
23345 &[
23346 spawn_spec_capability.clone(),
23347 worktree_capability.clone(),
23348 profile_revision_capability.clone(),
23349 ],
23350 ));
23351 let bundle_materialization_capability =
23358 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23359 assert!(request_uses_unnegotiated_capability(
23360 &request,
23361 &[
23362 spawn_spec_capability.clone(),
23363 worktree_capability.clone(),
23364 profile_revision_capability.clone(),
23365 bundle_materialization_capability.clone(),
23366 ],
23367 ));
23368 let history_context_pack_capability =
23369 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23370 assert!(!request_uses_unnegotiated_capability(
23371 &request,
23372 &[
23373 spawn_spec_capability,
23374 worktree_capability,
23375 profile_revision_capability,
23376 bundle_materialization_capability,
23377 history_context_pack_capability,
23378 ],
23379 ));
23380 }
23381
23382 #[tokio::test]
23383 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23384 let mut shared = terminal_test_shared();
23385 let spec = spawn_spec_fixture();
23386 let receipt = shared
23387 .resolve_spawn_spec(&spec)
23388 .unwrap()
23389 .receipt(shared.incarnation_id, terminal_address(1));
23390 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23391
23392 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23393 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23394 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23395
23396 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23397
23398 let mut revision_conflicting = spec.clone();
23399 revision_conflicting.expected_profile_revision =
23400 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23401 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23402 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23403
23404 let mut stale_cache_miss = spec.clone();
23405 stale_cache_miss.idempotency_key =
23406 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23407 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23408 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23409
23410 let mut conflicting = spec;
23411 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23412 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23413 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23414 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23415 assert!(shared.handle.snapshot().sessions.is_empty());
23416 }
23417
23418 #[tokio::test]
23419 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23420 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23421 let mut spec = spawn_spec_fixture();
23422 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23423 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23424 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23425
23426 let failure = shared
23427 .create_worktree(
23428 WorkspaceId::new("primary").unwrap(),
23429 WorkspaceId::new("new-worktree").unwrap(),
23430 "not-an-absolute-path".to_owned(),
23431 "invalid branch".to_owned(),
23432 None,
23433 )
23434 .await
23435 .unwrap_err();
23436 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23437
23438 let failure = shared
23439 .remove_worktree(
23440 WorkspaceId::new("primary").unwrap(),
23441 "not-an-absolute-path".to_owned(),
23442 )
23443 .await
23444 .unwrap_err();
23445 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23446 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23447 assert!(shared.handle.snapshot().sessions.is_empty());
23448 }
23449
23450 #[tokio::test]
23451 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23452 let root = temporary_workspace_root("manual-worktree-compensation");
23453 let source = root.join("source");
23454 let target = root.join("target");
23455 let invalid_state_parent = root.join("state-parent-is-a-file");
23456 let invalid_state_path = invalid_state_parent.join("state.json");
23457 std::fs::create_dir_all(&source).unwrap();
23458 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23459 run_test_git(&source, &["init"]);
23460 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23461 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23462 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23463 run_test_git(&source, &["add", "seed.txt"]);
23464 run_test_git(&source, &["commit", "-m", "seed"]);
23465
23466 let catalog = active_registry().unwrap();
23467 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23468 let workspace = WorkspaceConfig::new(
23469 WorkspaceId::new("primary").unwrap(),
23470 &source,
23471 ).unwrap();
23472 let mut shared = NodeShared::new(
23473 handle,
23474 "fixture-token".to_owned(),
23475 NodeId::new("node-worktree-compensation").unwrap(),
23476 vec![workspace],
23477 vec![agent("claude")],
23478 );
23479 shared.state_path = Some(invalid_state_path);
23480
23481 let error = shared.create_worktree(
23482 WorkspaceId::new("primary").unwrap(),
23483 WorkspaceId::new("compensated").unwrap(),
23484 target.to_string_lossy().into_owned(),
23485 "gate4agent/compensated".to_owned(),
23486 Some("HEAD".to_owned()),
23487 ).await.unwrap_err();
23488
23489 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23490 assert!(!target.exists());
23491 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23492 .iter()
23493 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23494 std::fs::remove_dir_all(root).unwrap();
23495 }
23496
23497 #[test]
23498 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23499 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23500 NativeGitWorktreeSnapshot {
23501 path: path.to_owned(),
23502 head: "0123456789abcdef".to_owned(),
23503 branch: Some("refs/heads/test".to_owned()),
23504 is_bare,
23505 is_main,
23506 locked: false,
23507 lock_reason: None,
23508 prunable: false,
23509 prunable_reason: None,
23510 workspace_id: None,
23511 }
23512 }
23513
23514 let source = r"C:\repo";
23515 let selected = r"C:\repo-worktree";
23516 let listed = vec![
23517 worktree(source, true, false),
23518 worktree(selected, false, false),
23519 ];
23520 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23521
23522 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23523 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23524
23525 let bare = vec![
23526 worktree(source, true, false),
23527 worktree(selected, false, true),
23528 ];
23529 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23530 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23531
23532 let foreign_failure =
23533 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23534 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23535 }
23536
23537 fn terminal_frame(sequence: u64) -> TerminalFrame {
23538 TerminalFrame {
23539 sequence,
23540 size: gate4agent_types::TerminalSize {
23541 rows: 24,
23542 columns: 80,
23543 },
23544 cursor_row: 2,
23545 cursor_column: 3,
23546 contents: format!("frame-{sequence}"),
23547 formatted: vec![1, 2, 3],
23548 scrollback_formatted: Vec::new(),
23549 alternate_screen: false,
23550 mouse_protocol_enabled: false,
23551 mouse_protocol_encoding: Default::default(),
23552 produced_at_unix_ms: 0,
23553 screen_state: gate4agent_types::PtyScreenState::default(),
23554 bracketed_paste: None,
23555 }
23556 }
23557
23558 #[test]
23559 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23560 let shared = terminal_test_shared();
23561 let mut receiver = shared.terminal_event_tx.subscribe();
23562 let original = terminal_address(1);
23563
23564 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23565 let first = receiver.try_recv().unwrap();
23566 assert_eq!(first.len(), 1);
23567 assert!(matches!(
23568 &first[0].event,
23569 NodeEvent::TerminalFrame { address, frame }
23570 if address == &original && frame.sequence == 7
23571 ));
23572
23573 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23574 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23575 assert!(matches!(
23576 receiver.try_recv(),
23577 Err(broadcast::error::TryRecvError::Empty)
23578 ));
23579
23580 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23581 let advanced = receiver.try_recv().unwrap();
23582 assert_eq!(advanced[0].sequence, 2);
23583
23584 let resumed = terminal_address(2);
23585 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23586 let reset = receiver.try_recv().unwrap();
23587 assert_eq!(reset[0].sequence, 3);
23588 assert!(matches!(
23589 &reset[0].event,
23590 NodeEvent::TerminalFrame { address, frame }
23591 if address == &resumed && frame.sequence == 1
23592 ));
23593 shared.clear_terminal_frame_watermark(&resumed);
23594 assert!(!shared
23595 .terminal_frame_watermarks
23596 .lock()
23597 .unwrap_or_else(|poisoned| poisoned.into_inner())
23598 .contains_key(&resumed.session.instance_id));
23599 }
23600
23601 #[test]
23602 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23603 let shared = terminal_test_shared();
23604 let mut receiver = shared.terminal_event_tx.subscribe();
23605 let address = terminal_address(1);
23606 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23607
23608 for sequence in 1..=count {
23609 shared.publish_terminal_frame_candidates(vec![(
23610 address.clone(),
23611 terminal_frame(sequence),
23612 )]);
23613 }
23614
23615 let history = shared
23616 .history
23617 .lock()
23618 .unwrap_or_else(|poisoned| poisoned.into_inner());
23619 assert!(history.events.is_empty());
23620 assert_eq!(history.last_sequence, count);
23621 assert_eq!(history.replay_floor_sequence, 1);
23622 drop(history);
23623 let NodeResponse::Resync {
23624 event_sequence,
23625 oldest_available_sequence,
23626 events,
23627 ..
23628 } = shared.resync(0) else {
23629 unreachable!("resync helper returned another response")
23630 };
23631 assert_eq!(event_sequence, count);
23632 assert_eq!(oldest_available_sequence, 1);
23633 assert!(events.is_empty());
23634 assert!(matches!(
23635 resync_required_event(&shared).event,
23636 NodeEvent::ResyncRequired {
23637 oldest_available_sequence: 1,
23638 }
23639 ));
23640 assert!(matches!(
23641 receiver.try_recv(),
23642 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23643 ));
23644 let latest = receiver.try_recv().unwrap();
23645 assert_eq!(latest.len(), 1);
23646 assert_eq!(latest[0].sequence, count);
23647 }
23648
23649 #[test]
23650 fn real_durable_eviction_advances_authoritative_replay_floor() {
23651 let shared = terminal_test_shared();
23652 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23653 shared.publish(NodeEvent::ControllerChanged { controller: None });
23654 }
23655
23656 let NodeResponse::Resync {
23657 event_sequence,
23658 oldest_available_sequence,
23659 events,
23660 ..
23661 } = shared.resync(0) else {
23662 unreachable!("resync helper returned another response")
23663 };
23664 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23665 assert_eq!(oldest_available_sequence, 2);
23666 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23667 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23668 assert!(matches!(
23669 resync_required_event(&shared).event,
23670 NodeEvent::ResyncRequired {
23671 oldest_available_sequence: 2,
23672 }
23673 ));
23674 }
23675
23676 #[test]
23677 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23678 let shared = terminal_test_shared();
23679 let without_capability = terminal_event_subscription(&shared, false);
23680 let with_capability = terminal_event_subscription(&shared, true);
23681 assert!(without_capability.is_none());
23682 assert!(with_capability.is_some());
23683 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23684 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23685 }));
23686 }
23687
23688 #[test]
23689 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23690 let mut pending = Vec::new();
23691 let mut event_sequence = 0_u64;
23692 for frame_sequence in 1..=4 {
23693 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23694 event_sequence += 1;
23695 let mut address = terminal_address(1);
23696 address.session.instance_id = AgentInstanceId(instance);
23697 queue_connection_event(
23698 &mut pending,
23699 NodeEventEnvelope {
23700 sequence: event_sequence,
23701 event: NodeEvent::TerminalFrame {
23702 address,
23703 frame: terminal_frame(frame_sequence),
23704 },
23705 },
23706 0,
23707 );
23708 }
23709 }
23710 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23711 pending.sort_unstable_by_key(|event| event.sequence);
23712
23713 let mut uninterrupted = pending.clone();
23714 let first_burst = connection_event_burst_len(uninterrupted.len());
23715 uninterrupted.drain(..first_burst);
23716 let second_burst = connection_event_burst_len(uninterrupted.len());
23717 uninterrupted.drain(..second_burst);
23718 assert!(uninterrupted.is_empty());
23719
23720 let (frame_tx, mut frame_rx) =
23721 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23722 frame_tx
23723 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23724 request_id: 77,
23725 request: NodeRequest::Snapshot,
23726 })))
23727 .unwrap();
23728 let burst_len = connection_event_burst_len(pending.len());
23729 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23730 pending.drain(..burst_len);
23731 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23732 assert!(matches!(
23733 frame_rx.try_recv(),
23734 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23735 request_id: 77,
23736 request: NodeRequest::Snapshot,
23737 })))
23738 ));
23739 }
23740
23741 #[test]
23742 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23743 let mut pending = Vec::new();
23744 for sequence in [7_u64, 8] {
23745 let mut address = terminal_address(1);
23746 address.session.instance_id = AgentInstanceId(sequence);
23747 queue_connection_event(
23748 &mut pending,
23749 NodeEventEnvelope {
23750 sequence,
23751 event: NodeEvent::TerminalFrame {
23752 address,
23753 frame: terminal_frame(sequence),
23754 },
23755 },
23756 0,
23757 );
23758 }
23759 let mut discard_through = 0;
23760 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23761 assert!(pending.is_empty());
23762
23763 let replayed = NodeEventEnvelope {
23764 sequence: 8,
23765 event: NodeEvent::TerminalFrame {
23766 address: terminal_address(1),
23767 frame: terminal_frame(8),
23768 },
23769 };
23770 let advanced = NodeEventEnvelope {
23771 sequence: 9,
23772 event: NodeEvent::TerminalFrame {
23773 address: terminal_address(2),
23774 frame: terminal_frame(1),
23775 },
23776 };
23777 queue_connection_event_batch(
23778 &mut pending,
23779 &[replayed, advanced.clone()],
23780 discard_through,
23781 );
23782 assert_eq!(pending, vec![advanced]);
23783 }
23784
23785 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23786 ManagedSessionRecord {
23787 record_id: SessionRecordId::new(record_id).unwrap(),
23788 display_name: format!("{provider} record"),
23789 provider: agent(provider),
23790 mode: SessionMode::Pty,
23791 state: ManagedSessionState::Unavailable,
23792 workspace_id: WorkspaceId::new("primary").unwrap(),
23793 canonical_root: opaque_windows_path(
23794 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23795 ),
23796 provider_session: None,
23797 active_session: None,
23798 environment_profile: None,
23799 bundle: None,
23800 context_id: None,
23801 context: None,
23802 exported_context: None,
23803 task_binding: None,
23804 created_at_unix_ms: 1,
23805 updated_at_unix_ms: 1,
23806 last_error: None,
23807 }
23808 }
23809
23810 fn retention_test_record(
23811 workspace_id: &str,
23812 record_id: &str,
23813 state: ManagedSessionState,
23814 updated_at_unix_ms: u64,
23815 ) -> ManagedSessionRecord {
23816 ManagedSessionRecord {
23817 record_id: SessionRecordId::new(record_id).unwrap(),
23818 display_name: format!("{record_id} display"),
23819 provider: agent("claude"),
23820 mode: SessionMode::Pty,
23821 state,
23822 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23823 canonical_root: opaque_windows_path(
23824 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23825 ),
23826 provider_session: None,
23827 active_session: None,
23828 environment_profile: None,
23829 bundle: None,
23830 context_id: None,
23831 context: None,
23832 exported_context: None,
23833 task_binding: None,
23834 created_at_unix_ms: updated_at_unix_ms,
23835 updated_at_unix_ms,
23836 last_error: None,
23837 }
23838 }
23839
23840 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23848 vec![
23849 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23850 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23851 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23852 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23853 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23854 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23855 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23856 retention_test_record(
23857 "workspace-a",
23858 "decoy-pending",
23859 ManagedSessionState::IdentityPending,
23860 now - 500_000,
23861 ),
23862 retention_test_record(
23863 "workspace-a",
23864 "decoy-dormant",
23865 ManagedSessionState::Dormant,
23866 now - 500_000,
23867 ),
23868 ]
23869 }
23870
23871 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23872 let root = temporary_workspace_root("session-record-retention");
23873 std::fs::create_dir_all(&root).unwrap();
23874 let catalog = active_registry().unwrap();
23875 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23876 let workspace_a =
23877 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23878 let workspace_b =
23879 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23880 let mut shared = NodeShared::new(
23881 handle,
23882 "fixture-token".to_owned(),
23883 NodeId::new("node-retention-test").unwrap(),
23884 vec![workspace_a, workspace_b],
23885 vec![agent("claude")],
23886 );
23887 shared.state_path = Some(root.join("node-state-v10.json"));
23888 let now = unix_time_ms();
23889 for record in retention_fixture_records(now) {
23890 shared.insert_record(record).unwrap();
23891 }
23892 shared
23893 .persist_state_locked()
23894 .expect("fixture-only, empty-history persist against a fresh temp state path");
23895 (shared, runtime, root, now)
23896 }
23897
23898 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23899 shared
23900 .history
23901 .lock()
23902 .unwrap_or_else(|poisoned| poisoned.into_inner())
23903 .events
23904 .iter()
23905 .filter_map(|envelope| match &envelope.event {
23906 NodeEvent::SessionRecordRemoved { record_id } => {
23907 Some(record_id.as_str().to_owned())
23908 }
23909 _ => None,
23910 })
23911 .collect()
23912 }
23913
23914 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23915 shared
23916 .session_records
23917 .lock()
23918 .unwrap_or_else(|poisoned| poisoned.into_inner())
23919 .records
23920 .keys()
23921 .map(|record_id| record_id.as_str().to_owned())
23922 .collect()
23923 }
23924
23925 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23926 values.iter().map(|value| (*value).to_owned()).collect()
23927 }
23928
23929 #[tokio::test]
23930 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23931 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23932 shared.session_record_retention = SessionRecordRetentionConfig {
23933 age_ms: 0,
23934 keep_per_workspace: 1,
23935 };
23936 shared.retire_unavailable_session_records().await;
23937
23938 assert_eq!(
23939 retention_remaining_record_ids(&shared),
23940 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23941 );
23942 assert_eq!(
23943 retention_removed_record_ids(&shared),
23944 owned(&["a1", "a2", "b1", "b2"]),
23945 );
23946 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23947
23948 drop(shared);
23949 drop(runtime);
23950 std::fs::remove_dir_all(root).unwrap();
23951 }
23952
23953 #[tokio::test]
23959 async fn session_record_retention_both_set_retires_only_the_intersection() {
23960 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23961 shared.session_record_retention = SessionRecordRetentionConfig {
23962 age_ms: 250_000,
23963 keep_per_workspace: 1,
23964 };
23965 shared.retire_unavailable_session_records().await;
23966
23967 assert_eq!(
23968 retention_remaining_record_ids(&shared),
23969 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23970 );
23971 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23972 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23973
23974 drop(shared);
23975 drop(runtime);
23976 std::fs::remove_dir_all(root).unwrap();
23977 }
23978
23979 #[tokio::test]
23980 async fn session_record_retention_both_disabled_is_a_no_op() {
23981 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23982 shared.session_record_retention = SessionRecordRetentionConfig::default();
23983 shared.retire_unavailable_session_records().await;
23984
23985 assert_eq!(
23986 retention_remaining_record_ids(&shared),
23987 owned(&[
23988 "a1", "a2", "a3", "b1", "b2", "b3",
23989 "decoy-live", "decoy-pending", "decoy-dormant",
23990 ]),
23991 );
23992 assert!(retention_removed_record_ids(&shared).is_empty());
23993 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23994
23995 drop(shared);
23996 drop(runtime);
23997 std::fs::remove_dir_all(root).unwrap();
23998 }
23999
24000 #[test]
24001 fn session_record_context_export_selects_one_exact_history_identity() {
24002 let candidates = vec![
24003 HistoryCandidateSummary {
24004 id: "candidate-other".to_owned(),
24005 session_id_hint: "provider-other".to_owned(),
24006 modified_at_unix_ms: Some(1),
24007 },
24008 HistoryCandidateSummary {
24009 id: "candidate-exact".to_owned(),
24010 session_id_hint: "provider-exact".to_owned(),
24011 modified_at_unix_ms: Some(2),
24012 },
24013 ];
24014 assert_eq!(
24015 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24016 "candidate-exact",
24017 );
24018 }
24019
24020 #[test]
24021 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24022 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24023 id: id.to_owned(),
24024 session_id_hint: hint.to_owned(),
24025 modified_at_unix_ms: None,
24026 };
24027 let missing = select_history_candidate(
24028 &[candidate("candidate-other", "provider-other")],
24029 Some("provider-exact"),
24030 )
24031 .unwrap_err();
24032 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24033 let ambiguous = select_history_candidate(
24034 &[
24035 candidate("candidate-a", "provider-exact"),
24036 candidate("candidate-b", "provider-exact"),
24037 ],
24038 Some("provider-exact"),
24039 )
24040 .unwrap_err();
24041 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24042 }
24043
24044 #[test]
24052 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24053 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24054 id: id.to_owned(),
24055 session_id_hint: hint.to_owned(),
24056 modified_at_unix_ms: None,
24057 };
24058 assert_eq!(
24059 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24060 .unwrap(),
24061 "candidate-sole",
24062 );
24063 let missing = select_history_candidate(&[], None).unwrap_err();
24064 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24065 let ambiguous = select_history_candidate(
24066 &[
24067 candidate("candidate-a", "sidecar-session-a"),
24068 candidate("candidate-b", "sidecar-session-b"),
24069 ],
24070 None,
24071 )
24072 .unwrap_err();
24073 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24074 }
24075
24076 #[test]
24077 fn session_record_context_export_revalidation_rejects_record_change() {
24078 let session = terminal_address(1);
24079 let identity = ProviderSessionIdentity {
24080 key: ProviderSessionKey::SessionId,
24081 id: "provider-exact".to_owned(),
24082 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24083 };
24084 let mut expected = record("claude", "record-context-export");
24085 expected.state = ManagedSessionState::Live;
24086 expected.provider_session = Some(identity.clone());
24087 expected.active_session = Some(session.clone());
24088 let binding = SessionBinding {
24089 workspace_id: session.workspace_id.clone(),
24090 generation: session.session.generation,
24091 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24092 pending_resume: None,
24093 record_id: Some(expected.record_id.clone()),
24094 managed_worktree_lease_id: None,
24095 environment_profile: None,
24096 bundle: None,
24097 context: None,
24098 materialization_id: None,
24099 };
24100 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24101 assert!(session_record_context_export_binding_is_exact(
24102 &expected,
24103 &expected,
24104 Some(&identity),
24105 &session,
24106 &binding,
24107 &expected.provider,
24108 ¤t_root,
24109 false,
24110 ));
24111 let mut changed = expected.clone();
24112 changed.updated_at_unix_ms += 1;
24113 assert!(!session_record_context_export_binding_is_exact(
24114 &expected,
24115 &changed,
24116 Some(&identity),
24117 &session,
24118 &binding,
24119 &expected.provider,
24120 ¤t_root,
24121 false,
24122 ));
24123 assert!(!session_record_context_export_binding_is_exact(
24128 &expected,
24129 &changed,
24130 Some(&identity),
24131 &session,
24132 &binding,
24133 &expected.provider,
24134 ¤t_root,
24135 true,
24136 ));
24137 let mut detached = expected.clone();
24138 detached.state = ManagedSessionState::Dormant;
24139 detached.active_session = None;
24140 detached.updated_at_unix_ms += 1;
24141 assert!(!session_record_context_export_binding_is_exact(
24142 &expected,
24143 &detached,
24144 Some(&identity),
24145 &session,
24146 &binding,
24147 &expected.provider,
24148 ¤t_root,
24149 false,
24150 ));
24151 assert!(session_record_context_export_binding_is_exact(
24152 &expected,
24153 &detached,
24154 Some(&identity),
24155 &session,
24156 &binding,
24157 &expected.provider,
24158 ¤t_root,
24159 true,
24160 ));
24161 }
24162
24163 #[test]
24170 fn session_record_context_export_binding_is_exact_without_identity() {
24171 let session = terminal_address(1);
24172 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24173 expected.state = ManagedSessionState::Live;
24174 expected.active_session = Some(session.clone());
24175 let binding = SessionBinding {
24176 workspace_id: session.workspace_id.clone(),
24177 generation: session.session.generation,
24178 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24179 pending_resume: None,
24180 record_id: Some(expected.record_id.clone()),
24181 managed_worktree_lease_id: None,
24182 environment_profile: None,
24183 bundle: None,
24184 context: None,
24185 materialization_id: None,
24186 };
24187 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24188 assert!(session_record_context_export_binding_is_exact(
24189 &expected,
24190 &expected,
24191 None,
24192 &session,
24193 &binding,
24194 &expected.provider,
24195 ¤t_root,
24196 false,
24197 ));
24198 let mut drifted = expected.clone();
24202 drifted.provider_session = Some(ProviderSessionIdentity {
24203 key: ProviderSessionKey::SessionId,
24204 id: "unexpected-identity".to_owned(),
24205 transcript_path: None,
24206 });
24207 assert!(!session_record_context_export_binding_is_exact(
24208 &expected,
24209 &drifted,
24210 None,
24211 &session,
24212 &binding,
24213 &expected.provider,
24214 ¤t_root,
24215 false,
24216 ));
24217 }
24218
24219 #[test]
24220 fn session_record_context_export_preflight_requires_live_usable_source() {
24221 let mut source = record("claude", "record-context-export-preflight");
24222 source.state = ManagedSessionState::Live;
24223 assert!(session_record_context_export_source_is_usable(
24224 &source,
24225 &SessionStatus::Running,
24226 false,
24227 ));
24228 assert!(session_record_context_export_source_is_usable(
24229 &source,
24230 &SessionStatus::Exited { exit_code: Some(0) },
24231 false,
24232 ));
24233
24234 source.state = ManagedSessionState::Dormant;
24235 assert!(!session_record_context_export_source_is_usable(
24236 &source,
24237 &SessionStatus::Running,
24238 false,
24239 ));
24240 assert!(session_record_context_export_source_is_usable(
24244 &source,
24245 &SessionStatus::Exited { exit_code: Some(0) },
24246 true,
24247 ));
24248 assert!(!session_record_context_export_source_is_usable(
24249 &source,
24250 &SessionStatus::Exited { exit_code: Some(0) },
24251 false,
24252 ));
24253 source.state = ManagedSessionState::Live;
24254 assert!(!session_record_context_export_source_is_usable(
24255 &source,
24256 &SessionStatus::Starting,
24257 false,
24258 ));
24259 }
24260
24261 #[test]
24262 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24263 let catalog = active_registry().unwrap();
24264 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24265 let workspace = WorkspaceConfig::new(
24266 WorkspaceId::new("primary").unwrap(),
24267 std::env::current_dir().unwrap(),
24268 )
24269 .unwrap();
24270 let shared = NodeShared::new(
24271 handle,
24272 "fixture-token".to_owned(),
24273 NodeId::new("node-context-export-conflict").unwrap(),
24274 vec![workspace],
24275 vec![agent("claude")],
24276 );
24277 let history = HistorySessionRecord {
24278 session_id: "provider-exact".to_owned(),
24279 title: None,
24280 cwd: None,
24281 model: None,
24282 message_count: 1,
24283 completed_turn_count: None,
24284 total_tokens: 0,
24285 messages: vec![gate4agent_types::HistoryMessageRecord {
24286 role: gate4agent_types::HistoryMessageRole::User,
24287 text: "bounded context".to_owned(),
24288 }],
24289 };
24290 let pack = NodeContextPack::export(
24291 ContextPackLineageReceipt {
24292 source_node_id: shared.node_id.clone(),
24293 source_session: terminal_address(1),
24294 source_provider: agent("claude"),
24295 },
24296 &history,
24297 )
24298 .unwrap();
24299 let context_id = pack.receipt().id.clone();
24300 let session = terminal_address(1);
24301 let identity = ProviderSessionIdentity {
24302 key: ProviderSessionKey::SessionId,
24303 id: history.session_id.clone(),
24304 transcript_path: None,
24305 };
24306 let mut expected = record("claude", "record-context-export-final-conflict");
24307 expected.state = ManagedSessionState::Live;
24308 expected.provider_session = Some(identity.clone());
24309 expected.active_session = Some(session.clone());
24310 let mut changed = expected.clone();
24311 changed.updated_at_unix_ms += 1;
24312 shared.insert_record(changed).unwrap();
24313
24314 let error = shared
24315 .commit_context_pack_for_session_record(
24316 &expected,
24317 Some(&identity),
24318 &session,
24319 "candidate-exact",
24320 &history,
24321 pack,
24322 false,
24323 )
24324 .unwrap_err();
24325
24326 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24327 assert!(shared
24328 .context_catalog
24329 .read()
24330 .unwrap_or_else(|poisoned| poisoned.into_inner())
24331 .get(&context_id)
24332 .is_none());
24333 }
24334
24335 #[tokio::test]
24336 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24337 let root = temporary_workspace_root("session-task-binding");
24338 std::fs::create_dir_all(&root).unwrap();
24339 let state_path = root.join("node-state.json");
24340 let catalog = active_registry().unwrap();
24341 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24342 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24343 let node_id = NodeId::new("node-1").unwrap();
24344 let mut shared = NodeShared::new(
24345 handle,
24346 "fixture-token".to_owned(),
24347 node_id.clone(),
24348 vec![workspace],
24349 vec![agent("claude")],
24350 );
24351 shared.state_path = Some(state_path.clone());
24352 let record = record("claude", "sr-task");
24353 let record_id = record.record_id.clone();
24354 shared.insert_record(record).unwrap();
24355 shared.persist_state().unwrap();
24356 shared
24357 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24358 .unwrap();
24359
24360 let minted = process_request_inner(
24361 &shared,
24362 77,
24363 ClientRole::Operator,
24364 NodeRequest::SetSessionTask {
24365 record_id: record_id.clone(),
24366 expected_revision: 0,
24367 target: SessionTaskTargetV1::New,
24368 },
24369 )
24370 .await
24371 .unwrap();
24372 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24373 panic!("session task mutation returned another response");
24374 };
24375 let minted_binding = minted.task_binding.clone().unwrap();
24376 let minted_task_id = minted_binding.task_id.clone().unwrap();
24377 assert_eq!(minted_binding.revision, 1);
24378 assert!(minted_task_id.as_str().starts_with("task-"));
24379 assert_eq!(minted_task_id.as_str().len(), 29);
24380
24381 let event_count = shared.current_sequence();
24382 let repeated = process_request_inner(
24383 &shared,
24384 77,
24385 ClientRole::Operator,
24386 NodeRequest::SetSessionTask {
24387 record_id: record_id.clone(),
24388 expected_revision: 1,
24389 target: SessionTaskTargetV1::Existing {
24390 task_id: minted_task_id.clone(),
24391 },
24392 },
24393 )
24394 .await
24395 .unwrap();
24396 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24397 assert_eq!(shared.current_sequence(), event_count);
24398
24399 let stale = process_request_inner(
24400 &shared,
24401 77,
24402 ClientRole::Operator,
24403 NodeRequest::SetSessionTask {
24404 record_id: record_id.clone(),
24405 expected_revision: 0,
24406 target: SessionTaskTargetV1::Existing {
24407 task_id: minted_task_id,
24408 },
24409 },
24410 )
24411 .await
24412 .unwrap_err();
24413 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24414
24415 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24416 let assigned = process_request_inner(
24417 &shared,
24418 77,
24419 ClientRole::Operator,
24420 NodeRequest::SetSessionTask {
24421 record_id: record_id.clone(),
24422 expected_revision: 1,
24423 target: SessionTaskTargetV1::Existing {
24424 task_id: assigned_id.clone(),
24425 },
24426 },
24427 )
24428 .await
24429 .unwrap();
24430 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24431 panic!("session task assignment returned another response");
24432 };
24433 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24434 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24435
24436 let cleared = process_request_inner(
24437 &shared,
24438 77,
24439 ClientRole::Operator,
24440 NodeRequest::SetSessionTask {
24441 record_id: record_id.clone(),
24442 expected_revision: 2,
24443 target: SessionTaskTargetV1::Clear,
24444 },
24445 )
24446 .await
24447 .unwrap();
24448 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24449 panic!("session task clear returned another response");
24450 };
24451 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24452 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24453 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24454 assert_eq!(
24455 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24456 cleared.updated_at_unix_ms,
24457 );
24458
24459 let persisted = std::fs::read(&state_path).unwrap();
24460 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24461 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24462 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24463 std::fs::remove_dir_all(root).unwrap();
24464 }
24465
24466 #[test]
24467 fn session_task_capability_strips_snapshots_events_and_replies() {
24468 let mut bound = record("claude", "sr-task-projection");
24469 bound.updated_at_unix_ms = 2;
24470 bound.task_binding = Some(SessionTaskBindingV1 {
24471 revision: 1,
24472 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24473 changed_at_unix_ms: 2,
24474 });
24475 let request = NodeRequest::SetSessionTask {
24476 record_id: bound.record_id.clone(),
24477 expected_revision: 1,
24478 target: SessionTaskTargetV1::Clear,
24479 };
24480 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24481 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24482 }));
24483 assert!(!request_uses_unnegotiated_capability(
24484 &request,
24485 &baseline_capabilities().unwrap(),
24486 ));
24487 assert!(request_uses_unnegotiated_capability(&request, &[]));
24488
24489 let mut snapshot = terminal_test_shared().snapshot();
24490 snapshot.session_records.push(bound.clone());
24491 let mut reply = ResponseEnvelope {
24492 request_id: 1,
24493 result: Ok(NodeResponse::Resync {
24494 event_sequence: 1,
24495 oldest_available_sequence: 1,
24496 snapshot,
24497 events: vec![NodeEventEnvelope {
24498 sequence: 1,
24499 event: NodeEvent::SessionRecordUpserted {
24500 record: bound.clone(),
24501 },
24502 }],
24503 }),
24504 };
24505 project_response_without_session_task_binding(&mut reply);
24506 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24507 panic!("task projection changed the response kind");
24508 };
24509 assert!(snapshot.session_records[0].task_binding.is_none());
24510 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24511 panic!("task projection changed the event kind");
24512 };
24513 assert!(record.task_binding.is_none());
24514
24515 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24516 sequence: 2,
24517 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24518 });
24519 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24520 panic!("task event projection changed the event kind");
24521 };
24522 assert!(record.task_binding.is_none());
24523
24524 let mut direct = ResponseEnvelope {
24525 request_id: 2,
24526 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24527 };
24528 project_response_without_session_task_binding(&mut direct);
24529 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24530 panic!("task reply projection changed the response kind");
24531 };
24532 assert!(record.task_binding.is_none());
24533 }
24534
24535 #[test]
24536 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24537 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24538 let workspace = WorkspaceConfig::new(
24539 WorkspaceId::new("primary").unwrap(),
24540 std::env::current_dir().unwrap(),
24541 )
24542 .unwrap();
24543 let config = NodeServerConfig::new(
24544 DEFAULT_NODE_ENDPOINT,
24545 "fixture-token",
24546 NodeId::new("node-1").unwrap(),
24547 [workspace],
24548 )
24549 .unwrap();
24550 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24551 }
24552
24553 #[cfg(all(feature = "fixture", windows))]
24554 #[test]
24555 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24556 let root = temporary_workspace_root("provider-bundle-argv-hold");
24557 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24558 std::fs::create_dir_all(&root).unwrap();
24559 std::fs::create_dir_all(&outside).unwrap();
24560 let proof_path = root.join("provider.proof");
24561 let release_signal = root.join("release.signal");
24562 let config = NodeServerConfig::new(
24563 format!(
24564 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24565 std::process::id(),
24566 ),
24567 "fixture-token",
24568 NodeId::new("provider-bundle-hold-node").unwrap(),
24569 [WorkspaceConfig::new(
24570 WorkspaceId::new("primary").unwrap(),
24571 std::env::current_dir().unwrap(),
24572 )
24573 .unwrap()],
24574 )
24575 .unwrap()
24576 .with_spawn_profiles(SpawnProfileRegistry::new([
24577 crate::protocol::SpawnProfileDefaults {
24578 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24579 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24580 .unwrap(),
24581 provider: agent("kimi"),
24582 mode: SessionMode::Pty,
24583 terminal_size: gate4agent_types::TerminalSize {
24584 rows: 24,
24585 columns: 80,
24586 },
24587 prompt: None,
24588 bundle_id: None,
24589 context_id: None,
24590 environment_profile_id: None,
24591 },
24592 ])
24593 .unwrap());
24594 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24595 config,
24596 agent("kimi"),
24597 proof_path.clone(),
24598 release_signal.clone(),
24599 )
24600 .unwrap();
24601 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24602
24603 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24604 agent("kimi"),
24605 proof_path.clone(),
24606 Some(release_signal.clone()),
24607 )
24608 .unwrap();
24609 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24610 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24611 let release_wait = wrapper
24612 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24613 .unwrap();
24614 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24615 assert!(proof_write < release_wait);
24616 assert!(release_wait < fixture_ready);
24617 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24618 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24619 assert_eq!(
24620 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24621 &[
24622 proof_path.to_string_lossy().into_owned(),
24623 release_signal.to_string_lossy().into_owned(),
24624 ],
24625 );
24626
24627 let error = NodeServer::provider_bundle_argv_fixture_spec(
24628 agent("kimi"),
24629 proof_path,
24630 Some(outside.join("release.signal")),
24631 )
24632 .unwrap_err();
24633 assert!(matches!(
24634 error,
24635 NodeServerError::Registry(message)
24636 if message == "provider bundle argv release path must share the proof parent"
24637 ));
24638 drop(server);
24639 std::fs::remove_dir_all(root).unwrap();
24640 std::fs::remove_dir_all(outside).unwrap();
24641 }
24642
24643 #[cfg(feature = "fixture")]
24644 #[test]
24645 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24646 let root = temporary_workspace_root("context-fixture-registry");
24647 std::fs::create_dir_all(&root).unwrap();
24648 let proof_path = root.join("context.proof");
24649 let config = NodeServerConfig::new(
24650 format!(
24651 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24652 std::process::id(),
24653 ),
24654 "fixture-token",
24655 NodeId::new("context-fixture-node").unwrap(),
24656 [WorkspaceConfig::new(
24657 WorkspaceId::new("primary").unwrap(),
24658 std::env::current_dir().unwrap(),
24659 )
24660 .unwrap()],
24661 )
24662 .unwrap();
24663 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24664 let mut enabled = server
24665 .shared
24666 .enabled_providers
24667 .iter()
24668 .map(AgentId::as_str)
24669 .collect::<Vec<_>>();
24670 enabled.sort_unstable();
24671 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24672 for provider in &server.shared.enabled_providers {
24673 assert!(server
24674 .shared
24675 .provider_adapter_contracts
24676 .iter()
24677 .any(|contract| {
24678 &contract.provider == provider
24679 && contract.family == AdapterFamily::History
24680 }));
24681 }
24682 drop(server);
24683 std::fs::remove_dir_all(root).unwrap();
24684 }
24685
24686 #[cfg(feature = "fixture")]
24687 #[test]
24688 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24689 let root = temporary_workspace_root("monitoring-context-fixture");
24690 std::fs::create_dir_all(&root).unwrap();
24691 let proof_path = root.join("context.proof");
24692 let standard = NodeServer::context_pack_fixture_catalog(
24693 proof_path.clone(),
24694 None,
24695 false,
24696 ).unwrap();
24697 let monitoring = NodeServer::context_pack_fixture_catalog(
24698 proof_path.clone(),
24699 None,
24700 true,
24701 ).unwrap();
24702 let claude = AgentId::new("claude").unwrap();
24703 let standard_claude = standard.get(&claude).unwrap();
24704 let monitoring_claude = monitoring.get(&claude).unwrap();
24705 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24709 assert_eq!(
24710 monitoring_claude.capabilities.adapters.history,
24711 standard_claude.capabilities.adapters.history,
24712 );
24713 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24714 let codex = AgentId::new("codex").unwrap();
24715 let standard_codex = standard.get(&codex).unwrap();
24716 let monitoring_codex = monitoring.get(&codex).unwrap();
24717 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24718 assert_eq!(
24719 monitoring_codex.capabilities.adapters.history,
24720 standard_codex.capabilities.adapters.history,
24721 );
24722 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24723 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24724 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24725 .unwrap();
24726 for required in [
24727 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24728 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24729 "@('role', 'text')",
24730 "g4a-private-provider-session-canary",
24731 "private-provider-login-identity",
24732 "$rawProof",
24733 "$env:GATE4AGENT_HOOK_URL",
24734 "g4a-private-provider-session-canary",
24735 ] {
24736 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24737 }
24738 for provider in ["grok", "kimi"] {
24739 let provider = AgentId::new(provider).unwrap();
24740 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24741 }
24742
24743 let config = NodeServerConfig::new(
24744 format!(
24745 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24746 std::process::id(),
24747 ),
24748 "fixture-token",
24749 NodeId::new("monitoring-context-fixture-node").unwrap(),
24750 [WorkspaceConfig::new(
24751 WorkspaceId::new("primary").unwrap(),
24752 std::env::current_dir().unwrap(),
24753 )
24754 .unwrap()],
24755 )
24756 .unwrap();
24757 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24758 assert!(server.shared.fixture_semantic_hook_policy);
24759 drop(server);
24760 std::fs::remove_dir_all(root).unwrap();
24761 }
24762
24763 #[cfg(feature = "fixture")]
24764 #[test]
24765 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24766 let root = temporary_workspace_root("kimi-context-only-fixture");
24767 std::fs::create_dir_all(&root).unwrap();
24768 let proof_path = root.join("context.proof");
24769 let standard = NodeServer::context_pack_fixture_catalog(
24770 proof_path.clone(),
24771 None,
24772 false,
24773 ).unwrap();
24774 let kimi = AgentId::new("kimi").unwrap();
24775 let context_only = NodeServer::context_pack_fixture_catalog(
24776 proof_path,
24777 Some(&kimi),
24778 false,
24779 )
24780 .unwrap();
24781
24782 let codex = AgentId::new("codex").unwrap();
24783 assert_eq!(
24784 standard.get(&codex).unwrap().launch.fixed_args,
24785 context_only.get(&codex).unwrap().launch.fixed_args,
24786 );
24787 let script = context_only
24788 .get(&kimi)
24789 .unwrap()
24790 .launch
24791 .fixed_args
24792 .iter()
24793 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24794 .expect("Kimi context-only validation script is missing");
24795 for required in [
24796 "$bundleArgs.Count -ne 0",
24797 "GATE4AGENT_CONTEXT_ROOT",
24798 "context-pack.json",
24799 "$entries.Count -ne 1",
24800 "g4a-context-pack-v1",
24801 "$document.PSObject.Properties['cwd']",
24802 "'claude', 'codex', 'grok', 'kimi'",
24803 "$hasUser",
24804 "$hasAssistant",
24805 "SHA256",
24806 "context-only",
24807 ] {
24808 assert!(script.contains(required), "missing validation: {required}");
24809 }
24810 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24811 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24812 }
24813 assert!(!standard
24814 .get(&kimi)
24815 .unwrap()
24816 .launch
24817 .fixed_args
24818 .iter()
24819 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24820
24821 std::fs::remove_dir_all(root).unwrap();
24822 }
24823
24824 #[cfg(all(feature = "fixture", windows))]
24825 #[tokio::test]
24826 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24827 let root = temporary_workspace_root("kimi-context-only-spawn");
24828 let workspace_root = root.join("workspace");
24829 std::fs::create_dir_all(&workspace_root).unwrap();
24830 let materialization_root = root.join("private-materializations");
24831 let proof_path = root.join("context.proof");
24832 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24833 let workspace_id = WorkspaceId::new("primary").unwrap();
24834 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24835 let endpoint = format!(
24836 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24837 std::process::id(),
24838 unix_time_ms(),
24839 );
24840 let spawn_profiles = SpawnProfileRegistry::new([
24841 crate::protocol::SpawnProfileDefaults {
24842 profile_id: profile_id.clone(),
24843 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24844 provider: agent("kimi"),
24845 mode: SessionMode::Pty,
24846 terminal_size: gate4agent_types::TerminalSize {
24847 rows: 30,
24848 columns: 120,
24849 },
24850 prompt: None,
24851 bundle_id: None,
24852 context_id: None,
24853 environment_profile_id: None,
24854 },
24855 ])
24856 .unwrap();
24857 let mut config = NodeServerConfig::new(
24858 &endpoint,
24859 "fixture-token",
24860 node_id.clone(),
24861 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24862 )
24863 .unwrap()
24864 .with_spawn_profiles(spawn_profiles)
24865 .with_session_environment_materialization(
24866 materialization_root,
24867 Arc::new(FixtureSecretResolver {
24868 deny: Arc::new(AtomicBool::new(false)),
24869 }),
24870 )
24871 .unwrap();
24872 config.fixture_raw_pty_runtime = true;
24873 let server = NodeServer::new_context_only_proof_fixture(
24874 config,
24875 agent("kimi"),
24876 proof_path.clone(),
24877 )
24878 .unwrap();
24879 let shared = Arc::clone(&server.shared);
24880 let shutdown = server.shutdown_handle();
24881
24882 let history = HistorySessionRecord {
24883 session_id: "context-only-source-history".to_owned(),
24884 title: None,
24885 cwd: None,
24886 model: None,
24887 message_count: 2,
24888 completed_turn_count: None,
24889 total_tokens: 0,
24890 messages: vec![
24891 gate4agent_types::HistoryMessageRecord {
24892 role: gate4agent_types::HistoryMessageRole::User,
24893 text: "continue the bounded parent run through native C2".to_owned(),
24894 },
24895 gate4agent_types::HistoryMessageRecord {
24896 role: gate4agent_types::HistoryMessageRole::Assistant,
24897 text: "the exact parent context is ready for the child run".to_owned(),
24898 },
24899 ],
24900 };
24901 let pack = NodeContextPack::export(
24902 ContextPackLineageReceipt {
24903 source_node_id: node_id.clone(),
24904 source_session: SessionAddress {
24905 workspace_id: workspace_id.clone(),
24906 session: SessionKey {
24907 instance_id: AgentInstanceId(71),
24908 generation: SessionGeneration(1),
24909 },
24910 },
24911 source_provider: agent("codex"),
24912 },
24913 &history,
24914 )
24915 .unwrap();
24916 let expected_context_hash = digest(&SHA256, pack.bytes())
24917 .as_ref()
24918 .iter()
24919 .map(|byte| format!("{byte:02x}"))
24920 .collect::<String>();
24921 let context = shared
24922 .context_catalog
24923 .write()
24924 .unwrap_or_else(|poisoned| poisoned.into_inner())
24925 .insert(pack)
24926 .unwrap();
24927 let spec = SpawnSpec {
24928 target: crate::protocol::SpawnTarget {
24929 node_id,
24930 workspace_id: workspace_id.clone(),
24931 worktree_id: None,
24932 },
24933 profile_id,
24934 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24935 "target-kimi-r1",
24936 ).unwrap(),
24937 overrides: crate::protocol::SpawnOverrides {
24938 context_id: crate::protocol::SpawnOverride::Set {
24939 value: context.id.clone(),
24940 },
24941 ..crate::protocol::SpawnOverrides::default()
24942 },
24943 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24944 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24945 required_capabilities: SpawnRequiredCapabilities::default(),
24946 };
24947 let resolved = match shared.resolve_spawn_spec(&spec) {
24948 Ok(resolved) => resolved,
24949 Err(error) => panic!(
24950 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24951 error.code,
24952 error.message,
24953 ),
24954 };
24955 let resolved_context = match shared.resolve_context(&resolved) {
24956 Ok(Some(resolved_context)) => resolved_context,
24957 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24958 Err(error) => panic!(
24959 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24960 error.code,
24961 error.message,
24962 ),
24963 };
24964 assert_eq!(resolved_context, context);
24965 let runtime_policy = match shared
24966 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24967 .await
24968 {
24969 Ok(runtime_policy) => runtime_policy,
24970 Err(error) => panic!(
24971 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24972 error.code,
24973 error.message,
24974 ),
24975 };
24976 assert!(runtime_policy.raw_pty_lifecycle);
24977
24978 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24979 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24980 agent("kimi"),
24981 TransportKind::Pty,
24982 vec![EnvMutation {
24983 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24984 value: Some(OsString::from("must-remain-rejected")),
24985 }],
24986 )
24987 .unwrap();
24988 assert_eq!(
24989 launch_control
24990 .install_native_launch_environment_overlay(
24991 AgentInstanceId(72),
24992 generic_overlay,
24993 )
24994 .unwrap_err(),
24995 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24996 );
24997
24998 let diagnostic_address = SessionAddress {
24999 workspace_id: workspace_id.clone(),
25000 session: SessionKey {
25001 instance_id: AgentInstanceId(73),
25002 generation: SessionGeneration(1),
25003 },
25004 };
25005 let (diagnostic_overlay, diagnostic_materialization) = match shared
25006 .prepare_session_materialization(
25007 &diagnostic_address,
25008 &agent("kimi"),
25009 SessionMode::Pty,
25010 None,
25011 None,
25012 Some(&resolved_context),
25013 None,
25014 )
25015 {
25016 Ok(Some(prepared)) => prepared,
25017 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25018 Err(error) => panic!(
25019 "Kimi context-only materialization rejected: exact_code={:?} message={}",
25020 error.code,
25021 error.message,
25022 ),
25023 };
25024 let diagnostic_overlay = diagnostic_overlay
25025 .expect("Kimi context-only materialization omitted its environment overlay");
25026 assert!(matches!(
25027 &diagnostic_overlay,
25028 PreparedNativeLaunchOverlay::Environment(_),
25029 ));
25030 match shared.install_prepared_launch_overlay(
25031 diagnostic_address.session.instance_id,
25032 diagnostic_overlay,
25033 ) {
25034 Ok(None) => {}
25035 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25036 Err(error) => panic!(
25037 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25038 error.code,
25039 error.message,
25040 ),
25041 }
25042 assert!(launch_control.clear_native_instance_launch_overlay(
25043 diagnostic_address.session.instance_id,
25044 ));
25045 drop(diagnostic_materialization);
25046 assert!(shared
25047 .materializations
25048 .lock()
25049 .unwrap_or_else(|poisoned| poisoned.into_inner())
25050 .is_empty());
25051
25052 let node_task = tokio::spawn(server.run());
25053 let ready_client = timeout(Duration::from_secs(5), async {
25054 loop {
25055 match gate4agent_node_wire::LocalNodeClient::connect(
25056 &endpoint,
25057 &shared.node_id,
25058 ClientRole::Observer,
25059 "fixture-token",
25060 )
25061 .await
25062 {
25063 Ok(client) => break client,
25064 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25065 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25066 }
25067 }
25068 })
25069 .await
25070 .expect("Kimi context-only Node did not become ready");
25071 assert_eq!(
25072 ready_client
25073 .hello()
25074 .snapshot
25075 .provider_runtime_statuses
25076 .iter()
25077 .find(|status| status.provider().as_str() == "kimi")
25078 .expect("Kimi runtime status is missing")
25079 .mode(),
25080 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25081 );
25082 drop(ready_client);
25083
25084 let receipt = match shared.spawn_from_spec(spec).await {
25085 Ok(receipt) => receipt,
25086 Err(error) => panic!(
25087 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25088 error.code,
25089 error.message,
25090 ),
25091 };
25092 assert_eq!(receipt.provider, agent("kimi"));
25093 assert_eq!(receipt.context.as_ref(), Some(&context));
25094 assert!(receipt.context_binding_is_valid());
25095 timeout(Duration::from_secs(5), async {
25096 while !proof_path.is_file() {
25097 sleep(Duration::from_millis(10)).await;
25098 }
25099 })
25100 .await
25101 .expect("Kimi context-only fixture did not write its proof");
25102
25103 let proof = std::fs::read_to_string(&proof_path).unwrap();
25104 let lines = proof.lines().collect::<Vec<_>>();
25105 assert_eq!(lines.len(), 7);
25106 assert_eq!(lines[0], "context-only");
25107 assert!(Path::new(lines[1]).is_absolute());
25108 assert_ne!(
25109 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25110 std::fs::canonicalize(&workspace_root).unwrap(),
25111 );
25112 assert_eq!(
25113 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25114 std::fs::canonicalize(&workspace_root).unwrap(),
25115 );
25116 assert_eq!(lines[3], expected_context_hash);
25117 assert_eq!(lines[4], "g4a-context-pack-v1");
25118 assert_eq!(lines[5], "codex");
25119 assert_eq!(lines[6], "2");
25120 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25121 session.instance_id == receipt.session.session.instance_id
25122 && session.agent_id.as_str() == "kimi"
25123 }));
25124
25125 shutdown.request_shutdown().await.unwrap();
25126 timeout(Duration::from_secs(5), node_task)
25127 .await
25128 .unwrap()
25129 .unwrap()
25130 .unwrap();
25131 drop(shutdown);
25132 drop(shared);
25133 std::fs::remove_dir_all(root).unwrap();
25134 }
25135
25136 #[cfg(feature = "fixture")]
25137 #[test]
25138 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25139 let root = temporary_workspace_root("clean-exit-constructor");
25140 std::fs::create_dir_all(&root).unwrap();
25141 let config = construction_test_config("clean-exit-constructor")
25142 .with_state_path(root.join("node-state.json"))
25143 .unwrap();
25144 let server = NodeServer::new_clean_exit_fixture(
25145 config,
25146 root.clone(),
25147 root.join("started.marker"),
25148 root.join("release.signal"),
25149 )
25150 .unwrap();
25151 assert_eq!(
25152 server
25153 .shared
25154 .enabled_providers
25155 .iter()
25156 .map(AgentId::as_str)
25157 .collect::<Vec<_>>(),
25158 ["claude"],
25159 );
25160 drop(server);
25161 std::fs::remove_dir_all(root).unwrap();
25162 }
25163
25164 #[test]
25165 fn production_registry_provider_contract_manifest_is_exact() {
25166 let registry = active_registry().unwrap();
25167 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25168 assert_eq!(
25169 providers
25170 .iter()
25171 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25172 .collect::<Vec<_>>(),
25173 vec![
25174 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25175 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25176 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25177 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25178 ]
25179 );
25180 assert_eq!(
25181 adapters
25182 .iter()
25183 .map(|contract| (
25184 contract.provider.as_str(),
25185 contract.family,
25186 contract.adapter_id.as_str(),
25187 contract.revision.as_str(),
25188 ))
25189 .collect::<Vec<_>>(),
25190 vec![
25191 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25192 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25193 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25194 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25195 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25196 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25197 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25198 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25199 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25200 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25201 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25202 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25203 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25204 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25205 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25206 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25207 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25208 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25209 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25210 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25211 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25212 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25213 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25214 ]
25215 );
25216 }
25217
25218 fn construction_test_config(name: &str) -> NodeServerConfig {
25219 let workspace = WorkspaceConfig::new(
25220 WorkspaceId::new("primary").unwrap(),
25221 std::env::current_dir().unwrap(),
25222 )
25223 .unwrap();
25224 NodeServerConfig::new(
25225 format!(r"\\.\pipe\gate4agent-{name}"),
25226 "fixture-token",
25227 NodeId::new(name).unwrap(),
25228 [workspace],
25229 )
25230 .unwrap()
25231 }
25232
25233 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25234 let adapter = AdapterBinding::new(
25235 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25236 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25237 gate4agent_types::AdapterVerification::SyntheticFixture,
25238 )
25239 .unwrap();
25240 let agent_ids = ["claude", "codex", "kimi"]
25241 .into_iter()
25242 .map(|agent_id| AgentId::new(agent_id).unwrap())
25243 .collect::<Vec<_>>();
25244 let adapters = gate4agent_catalog::AdapterRegistry::new(
25245 [
25246 AdapterFamily::PtySemantic,
25247 AdapterFamily::Pipe,
25248 AdapterFamily::OneShot,
25249 AdapterFamily::Acp,
25250 AdapterFamily::Hook,
25251 AdapterFamily::ManagedHook,
25252 AdapterFamily::History,
25253 AdapterFamily::Resume,
25254 AdapterFamily::SessionOptions,
25255 AdapterFamily::CapabilityProbe,
25256 ]
25257 .into_iter()
25258 .map(|family| gate4agent_catalog::AdapterDescriptor {
25259 family,
25260 binding: adapter.clone(),
25261 agents: agent_ids.clone(),
25262 }),
25263 )
25264 .unwrap();
25265 let specs = agent_ids
25266 .iter()
25267 .map(|agent_id| {
25268 let agent_id = agent_id.as_str();
25269 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25270 spec.revision =
25271 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25272 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25273 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25274 adapter: adapter.clone(),
25275 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25276 launch_override: None,
25277 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25278 });
25279 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25280 adapter: adapter.clone(),
25281 launch_override: None,
25282 });
25283 spec.capabilities.adapters.hook = Some(adapter.clone());
25284 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25285 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25286 spec.capabilities.adapters.history = Some(adapter.clone());
25287 spec.capabilities.adapters.resume = Some(adapter.clone());
25288 spec.capabilities.adapters.session_options = Some(adapter.clone());
25289 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25290 spec
25291 })
25292 .collect::<Vec<_>>();
25293 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25294 }
25295
25296 #[test]
25304 fn node_server_construction_accepts_the_production_manifest() {
25305 let registry = active_registry().unwrap();
25306 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25307 assert_eq!(providers.len(), registry.iter().count());
25308 assert!(!providers.is_empty());
25309 let server = NodeServer::new_with_registry(
25310 construction_test_config("manifest-production-bound"),
25311 registry,
25312 )
25313 .unwrap();
25314 drop(server);
25315 }
25316
25317 #[test]
25318 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25319 let registry = maximum_size_provider_manifest_registry();
25320 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25321 assert_eq!(providers.len(), 3);
25322 assert_eq!(adapters.len(), 30);
25323 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25324 let error = match NodeServer::new_with_registry(
25325 construction_test_config("manifest-handshake-overflow"),
25326 registry,
25327 ) {
25328 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25329 Err(error) => error,
25330 };
25331 assert!(matches!(
25332 error,
25333 NodeServerError::ProviderContractManifest(message)
25334 if message.contains("authentication binding length")
25335 && message.contains("8192-byte limit")
25336 ));
25337 }
25338
25339 #[test]
25340 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25341 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25342 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25343 let registry = AgentRegistry::new([spec]).unwrap();
25344 assert!(matches!(
25345 provider_contract_manifest(®istry),
25346 Err(NodeServerError::ProviderContractManifest(message))
25347 if message.contains("exceeds the 128-byte limit")
25348 ));
25349 }
25350
25351 #[test]
25352 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25353 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25354 spec.revision = "invalid revision".to_owned();
25355 let registry = AgentRegistry::new([spec]).unwrap();
25356 assert!(matches!(
25357 provider_contract_manifest(®istry),
25358 Err(NodeServerError::ProviderContractManifest(message))
25359 if message.contains("bounded lowercase ASCII")
25360 ));
25361 }
25362
25363 #[test]
25364 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25365 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25366 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25367 let conflicting_binding = builtin_registry()
25368 .get_by_id("codex")
25369 .unwrap()
25370 .capabilities
25371 .adapters
25372 .one_shot
25373 .clone()
25374 .unwrap();
25375 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25376 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25377 pipe.adapter = transport_binding;
25378 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25379 let registry = AgentRegistry::new([spec]).unwrap();
25380 assert!(matches!(
25381 provider_contract_manifest(®istry),
25382 Err(NodeServerError::ProviderContractManifest(message))
25383 if message.contains("conflicting OneShot adapter bindings")
25384 ));
25385 }
25386
25387 #[test]
25388 fn public_node_failures_expose_only_fixed_categories() {
25389 let provider = failure(
25390 NodeFailureCode::BackendOperationFailed,
25391 r"provider bearer-secret failed at C:\private\session.jsonl",
25392 );
25393 assert_eq!(provider.message, "backend-operation-failed");
25394 assert!(!provider.message.contains("bearer-secret"));
25395 assert!(!provider.message.contains(r"C:\private"));
25396 let persistence = persistence_failure(io::Error::new(
25397 io::ErrorKind::Other,
25398 r"state write failed at C:\private\state-v1.json",
25399 ));
25400 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25401 assert!(!persistence.message.contains(r"C:\private"));
25402
25403 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25404 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25405 assert_eq!(
25406 generic.to_string(),
25407 "node durable state failed: durable-state-load-failed",
25408 );
25409 }
25410 }
25411
25412 #[test]
25413 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25414 let sequence = b"\x1b[1;5D".to_vec();
25415 assert_eq!(
25416 terminal_bytes_action(sequence.clone()).unwrap(),
25417 InputAction::TerminalBytes(sequence),
25418 );
25419 assert_eq!(
25420 terminal_bytes_action(Vec::new()).unwrap_err().code,
25421 NodeFailureCode::InvalidRequest,
25422 );
25423 assert_eq!(
25424 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25425 .unwrap_err()
25426 .code,
25427 NodeFailureCode::InvalidRequest,
25428 );
25429 }
25430
25431 #[test]
25432 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25433 let root = temporary_workspace_root("exclusive-state-owner");
25434 std::fs::create_dir_all(&root).unwrap();
25435 let workspace = WorkspaceConfig::new(
25436 WorkspaceId::new("primary").unwrap(),
25437 &root,
25438 )
25439 .unwrap();
25440 let state_path = root.join("state-v1.json");
25441 let endpoint = format!(
25442 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25443 std::process::id(),
25444 std::time::SystemTime::now()
25445 .duration_since(std::time::UNIX_EPOCH)
25446 .unwrap()
25447 .as_nanos(),
25448 );
25449 let config = NodeServerConfig::new(
25450 endpoint,
25451 "fixture-token",
25452 NodeId::new("node-1").unwrap(),
25453 [workspace],
25454 )
25455 .unwrap()
25456 .with_state_path(&state_path)
25457 .unwrap();
25458
25459 let first = NodeServer::new(config.clone()).unwrap();
25460 let error = match NodeServer::new(config.clone()) {
25461 Ok(_) => panic!("a second node server acquired the same durable state path"),
25462 Err(error) => error,
25463 };
25464 assert!(matches!(
25465 &error,
25466 NodeServerError::DurableState(error)
25467 if error.kind() == io::ErrorKind::WouldBlock
25468 ));
25469 assert_eq!(
25470 error.to_string(),
25471 "node durable state failed: durable-state-lock-failed",
25472 );
25473 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25474 drop(first);
25475 let second = NodeServer::new(config).unwrap();
25476 drop(second);
25477 std::fs::remove_dir_all(root).unwrap();
25478 }
25479
25480 #[test]
25481 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25482 let catalog = active_registry().unwrap();
25483 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25484 let workspace_id = WorkspaceId::new("primary").unwrap();
25485 let workspace = WorkspaceConfig::new(
25486 workspace_id.clone(),
25487 std::env::current_dir().unwrap(),
25488 )
25489 .unwrap();
25490 let shared = NodeShared::new(
25491 handle,
25492 "fixture-token".to_owned(),
25493 NodeId::new("node-1").unwrap(),
25494 vec![workspace.clone()],
25495 vec![agent("claude"), agent("codex"), agent("kimi")],
25496 );
25497 let instance_id = AgentInstanceId(17);
25498 let generation = SessionGeneration(3);
25499 assert!(shared.address_for(instance_id, generation).is_none());
25500 shared.bind_session(instance_id, workspace_id.clone(), generation);
25501 let address = SessionAddress {
25502 workspace_id,
25503 session: SessionKey { instance_id, generation },
25504 };
25505 assert_eq!(
25506 shared.workspace_root(&address.workspace_id).unwrap(),
25507 workspace.canonical_root,
25508 );
25509 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25510 shared.remove_binding(&address);
25511 assert!(shared.address_for(instance_id, generation).is_none());
25512 }
25513
25514 #[test]
25515 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25516 let address = SessionAddress {
25517 workspace_id: WorkspaceId::new("primary").unwrap(),
25518 session: SessionKey {
25519 instance_id: AgentInstanceId(7),
25520 generation: SessionGeneration(2),
25521 },
25522 };
25523 let record_id = SessionRecordId::new("record-a").unwrap();
25524 let size = gate4agent_types::TerminalSize {
25525 rows: 24,
25526 columns: 80,
25527 };
25528 let address_requests = [
25529 NodeRequest::Resume {
25530 session: address.clone(),
25531 terminal_size: size,
25532 initial_prompt: None,
25533 },
25534 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25535 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25536 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25537 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25538 NodeRequest::TerminalControl {
25539 session: address.clone(),
25540 control: TerminalControl::Enter,
25541 },
25542 NodeRequest::Resize { session: address.clone(), size },
25543 NodeRequest::Interrupt { session: address.clone() },
25544 NodeRequest::Stop { session: address.clone(), force: false },
25545 NodeRequest::Remove { session: address },
25546 ];
25547 for request in &address_requests {
25548 assert!(request_requires_open_provider_ids_with(
25549 request,
25550 |_| Some(agent("third-party-agent")),
25551 |_| Some(agent("claude")),
25552 ));
25553 assert!(!request_requires_open_provider_ids_with(
25554 request,
25555 |_| Some(agent("claude")),
25556 |_| Some(agent("claude")),
25557 ));
25558 assert!(request_requires_open_provider_ids_with(
25559 request,
25560 |_| None,
25561 |_| Some(agent("claude")),
25562 ));
25563 }
25564
25565 let record_requests = [
25566 NodeRequest::RenameSessionRecord {
25567 record_id: record_id.clone(),
25568 display_name: "renamed".to_owned(),
25569 },
25570 NodeRequest::ResumeSessionRecord {
25571 record_id: record_id.clone(),
25572 terminal_size: size,
25573 initial_prompt: None,
25574 },
25575 NodeRequest::ForgetSessionRecord { record_id },
25576 ];
25577 for request in &record_requests {
25578 assert!(request_requires_open_provider_ids_with(
25579 request,
25580 |_| Some(agent("claude")),
25581 |_| Some(agent("grok")),
25582 ));
25583 assert!(!request_requires_open_provider_ids_with(
25584 request,
25585 |_| Some(agent("claude")),
25586 |_| Some(agent("kimi")),
25587 ));
25588 assert!(request_requires_open_provider_ids_with(
25589 request,
25590 |_| Some(agent("claude")),
25591 |_| None,
25592 ));
25593 }
25594
25595 assert!(request_requires_open_provider_ids_with(
25596 &NodeRequest::Spawn {
25597 workspace_id: WorkspaceId::new("primary").unwrap(),
25598 provider: agent("third-party-agent"),
25599 mode: SessionMode::Pty,
25600 terminal_size: size,
25601 initial_prompt: None,
25602 },
25603 |_| None,
25604 |_| None,
25605 ));
25606 assert!(!request_requires_open_provider_ids_with(
25607 &NodeRequest::Snapshot,
25608 |_| None,
25609 |_| None,
25610 ));
25611 assert!(request_requires_open_provider_ids_with(
25612 &NodeRequest::ForgetContextPack {
25613 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25614 },
25615 |_| None,
25616 |_| None,
25617 ));
25618 }
25619
25620 #[test]
25621 fn legacy_projection_filters_open_record_replies_and_event_history() {
25622 let catalog = active_registry().unwrap();
25623 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25624 let workspace = WorkspaceConfig::new(
25625 WorkspaceId::new("primary").unwrap(),
25626 std::env::current_dir().unwrap(),
25627 )
25628 .unwrap();
25629 let shared = NodeShared::new(
25630 handle,
25631 "fixture-token".to_owned(),
25632 NodeId::new("node-1").unwrap(),
25633 vec![workspace],
25634 vec![agent("claude"), agent("grok")],
25635 );
25636 let legacy = record("claude", "legacy-record");
25637 let open = record("grok", "open-record");
25638 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25639 record: legacy.clone(),
25640 });
25641 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25642 record: open.clone(),
25643 });
25644 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25645 record_id: legacy.record_id.clone(),
25646 });
25647 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25648 record_id: open.record_id.clone(),
25649 });
25650 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25651 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25652 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25653 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25654
25655 let mut record_reply = ResponseEnvelope {
25656 request_id: 1,
25657 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25658 };
25659 project_response_legacy_provider_ids(&shared, &mut record_reply);
25660 assert!(matches!(
25661 record_reply.result,
25662 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25663 ));
25664
25665 let mut resync = ResponseEnvelope {
25666 request_id: 2,
25667 result: Ok(NodeResponse::Resync {
25668 event_sequence: open_removed.sequence,
25669 oldest_available_sequence: 1,
25670 snapshot: shared.snapshot(),
25671 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25672 }),
25673 };
25674 project_response_legacy_provider_ids(&shared, &mut resync);
25675 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25676 panic!("legacy projection changed the resync response kind");
25677 };
25678 assert!(snapshot.session_records.iter().all(|item| {
25679 provider_id_is_legacy(&item.provider)
25680 }));
25681 assert_eq!(events.len(), 2);
25682 assert!(events.iter().all(|event| matches!(
25683 &event.event,
25684 NodeEvent::SessionRecordUpserted { record }
25685 if provider_id_is_legacy(&record.provider)
25686 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25687 }
25688
25689 #[test]
25690 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25691 let catalog = active_registry().unwrap();
25692 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25693 let workspace_id = WorkspaceId::new("primary").unwrap();
25694 let workspace = WorkspaceConfig::new(
25695 workspace_id.clone(),
25696 std::env::current_dir().unwrap(),
25697 )
25698 .unwrap();
25699 let shared = NodeShared::new(
25700 handle,
25701 "fixture-token".to_owned(),
25702 NodeId::new("node-1").unwrap(),
25703 vec![workspace],
25704 vec![agent("claude")],
25705 );
25706 for index in 0..CONTROL_SESSIONS_MAX {
25707 shared.bind_session(
25708 AgentInstanceId((index + 1) as u64),
25709 workspace_id.clone(),
25710 SessionGeneration(1),
25711 );
25712 }
25713 let error = shared.ensure_binding_capacity().unwrap_err();
25714 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25715 }
25716
25717 #[tokio::test]
25718 async fn provider_spawn_admission_rejects_before_session_mutation() {
25719 let catalog = active_registry().unwrap();
25720 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25721 let workspace = WorkspaceConfig::new(
25722 WorkspaceId::new("primary").unwrap(),
25723 std::env::current_dir().unwrap(),
25724 )
25725 .unwrap();
25726 let mut shared = NodeShared::new(
25727 handle,
25728 "fixture-token".to_owned(),
25729 NodeId::new("node-provider-admission").unwrap(),
25730 vec![workspace],
25731 vec![agent("claude")],
25732 );
25733 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25734 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25735 ])
25736 .unwrap();
25737 let terminal_size = gate4agent_types::TerminalSize {
25738 rows: 24,
25739 columns: 80,
25740 };
25741
25742 let unavailable = shared
25743 .spawn_session(
25744 WorkspaceId::new("primary").unwrap(),
25745 agent("claude"),
25746 SessionMode::Pty,
25747 terminal_size,
25748 None,
25749 )
25750 .await
25751 .unwrap_err();
25752 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25753 assert_eq!(unavailable.message, "backend-operation-failed");
25754
25755 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25756 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25757 agent("claude"),
25758 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25759 ),
25760 ])
25761 .unwrap();
25762 let semantic = shared
25763 .spawn_session(
25764 WorkspaceId::new("primary").unwrap(),
25765 agent("claude"),
25766 SessionMode::Inline,
25767 terminal_size,
25768 None,
25769 )
25770 .await
25771 .unwrap_err();
25772 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25773 assert_eq!(semantic.message, "unsupported-capability");
25774
25775 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25776 assert!(shared
25777 .session_bindings
25778 .lock()
25779 .unwrap_or_else(|poisoned| poisoned.into_inner())
25780 .is_empty());
25781 assert!(shared
25782 .session_records
25783 .lock()
25784 .unwrap_or_else(|poisoned| poisoned.into_inner())
25785 .records
25786 .is_empty());
25787 assert!(shared.handle.snapshot().sessions.is_empty());
25788 }
25789
25790 #[tokio::test]
25791 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25792 let catalog = active_registry().unwrap();
25793 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25794 let workspace_id = WorkspaceId::new("primary").unwrap();
25795 let workspace = WorkspaceConfig::new(
25796 workspace_id.clone(),
25797 std::env::current_dir().unwrap(),
25798 )
25799 .unwrap();
25800 let shared = NodeShared::new(
25801 handle,
25802 "fixture-token".to_owned(),
25803 NodeId::new("node-raw-spawn-record").unwrap(),
25804 vec![workspace],
25805 vec![agent("claude")],
25806 );
25807 let address = SessionAddress {
25808 workspace_id,
25809 session: SessionKey {
25810 instance_id: AgentInstanceId(71),
25811 generation: SessionGeneration::default(),
25812 },
25813 };
25814
25815 let record_id = shared
25816 .bind_spawn_session(
25817 &address,
25818 agent("claude"),
25819 SessionMode::Pty,
25820 ProviderRuntimePolicy::raw_pty(),
25821 SpawnRecordPolicy::Always,
25822 None,
25823 )
25824 .unwrap()
25825 .unwrap();
25826 shared
25827 .handle
25828 .dispatch(shared.prepare_command(ControlCommand::Register {
25829 instance_id: address.session.instance_id,
25830 agent_id: agent("claude"),
25831 transport: TransportKind::Pty,
25832 }))
25833 .unwrap();
25834 runtime.tick().await;
25835
25836 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25837 session.instance_id == address.session.instance_id
25838 && session.generation == address.session.generation
25839 }));
25840 let record = shared.record(&record_id).unwrap();
25841 assert_eq!(record.display_name, "claude #1");
25842 assert_eq!(record.state, ManagedSessionState::Live);
25843 assert_eq!(record.active_session.as_ref(), Some(&address));
25844 assert!(record.provider_session.is_none());
25845 let binding_record_id = shared
25846 .session_bindings
25847 .lock()
25848 .unwrap_or_else(|poisoned| poisoned.into_inner())
25849 .get(&address.session.instance_id)
25850 .and_then(|binding| binding.record_id.clone());
25851 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25852
25853 let renamed = shared
25854 .rename_session_record(&record_id, "raw review".to_owned())
25855 .unwrap();
25856 assert_eq!(renamed.record_id, record_id);
25857 assert_eq!(renamed.display_name, "raw review");
25858 shared.reconcile_managed_record(
25859 &record_id,
25860 &address,
25861 &ControlEvent {
25862 sequence: 1,
25863 command_id: None,
25864 instance_id: address.session.instance_id,
25865 generation: address.session.generation,
25866 event: ControlEventKind::Running { process_id: None },
25867 },
25868 false,
25869 false,
25870 );
25871 let running = shared.record(&record_id).unwrap();
25872 assert_eq!(running.state, ManagedSessionState::Live);
25873 assert_eq!(running.active_session.as_ref(), Some(&address));
25874 assert_eq!(running.display_name, "raw review");
25875
25876 shared.reconcile_managed_record(
25877 &record_id,
25878 &address,
25879 &ControlEvent {
25880 sequence: 2,
25881 command_id: None,
25882 instance_id: address.session.instance_id,
25883 generation: address.session.generation,
25884 event: ControlEventKind::Exited {
25885 exit_code: Some(0),
25886 forced: false,
25887 },
25888 },
25889 false,
25890 false,
25891 );
25892 let detached = shared.record(&record_id).unwrap();
25893 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25894 assert!(detached.active_session.is_none());
25895 assert_eq!(detached.display_name, "raw review");
25896 let renamed_detached = shared
25897 .rename_session_record(&record_id, "raw review detached".to_owned())
25898 .unwrap();
25899 assert_eq!(renamed_detached.record_id, record_id);
25900 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25901 assert!(renamed_detached.active_session.is_none());
25902 assert_eq!(renamed_detached.display_name, "raw review detached");
25903 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25904 }
25905
25906 #[test]
25907 fn legacy_raw_spawn_binding_remains_untracked() {
25908 let catalog = active_registry().unwrap();
25909 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25910 let workspace_id = WorkspaceId::new("primary").unwrap();
25911 let workspace = WorkspaceConfig::new(
25912 workspace_id.clone(),
25913 std::env::current_dir().unwrap(),
25914 )
25915 .unwrap();
25916 let shared = NodeShared::new(
25917 handle,
25918 "fixture-token".to_owned(),
25919 NodeId::new("node-legacy-raw-spawn").unwrap(),
25920 vec![workspace],
25921 vec![agent("claude")],
25922 );
25923 let address = SessionAddress {
25924 workspace_id,
25925 session: SessionKey {
25926 instance_id: AgentInstanceId(72),
25927 generation: SessionGeneration::default(),
25928 },
25929 };
25930
25931 let record_id = shared
25932 .bind_spawn_session(
25933 &address,
25934 agent("claude"),
25935 SessionMode::Pty,
25936 ProviderRuntimePolicy::raw_pty(),
25937 SpawnRecordPolicy::ProviderIdentityOnly,
25938 None,
25939 )
25940 .unwrap();
25941
25942 assert!(record_id.is_none());
25943 let binding_record_id = shared
25944 .session_bindings
25945 .lock()
25946 .unwrap_or_else(|poisoned| poisoned.into_inner())
25947 .get(&address.session.instance_id)
25948 .and_then(|binding| binding.record_id.clone());
25949 assert!(binding_record_id.is_none());
25950 assert!(shared.snapshot().session_records.is_empty());
25951 }
25952
25953 #[tokio::test]
25954 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25955 let catalog = active_registry().unwrap();
25956 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25957 let workspace_id = WorkspaceId::new("primary").unwrap();
25958 let workspace = WorkspaceConfig::new(
25959 workspace_id.clone(),
25960 std::env::current_dir().unwrap(),
25961 )
25962 .unwrap();
25963 let mut shared = NodeShared::new(
25964 handle,
25965 "fixture-token".to_owned(),
25966 NodeId::new("node-raw-semantic-gate").unwrap(),
25967 vec![workspace],
25968 vec![agent("claude")],
25969 );
25970 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25971 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25972 ])
25973 .unwrap();
25974 shared
25975 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25976 .unwrap();
25977 let address = SessionAddress {
25978 workspace_id,
25979 session: SessionKey {
25980 instance_id: AgentInstanceId(72),
25981 generation: SessionGeneration::default(),
25982 },
25983 };
25984 shared.bind_session_with_policy(
25985 &address,
25986 ProviderRuntimePolicy::raw_pty(),
25987 None,
25988 );
25989 shared
25990 .handle
25991 .dispatch(shared.prepare_command(ControlCommand::Register {
25992 instance_id: address.session.instance_id,
25993 agent_id: agent("claude"),
25994 transport: TransportKind::Pty,
25995 }))
25996 .unwrap();
25997 runtime.tick().await;
25998 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25999 let revision_before = shared.handle.snapshot().revision;
26000 let history_sequence_before = shared.current_sequence();
26001 let terminal_size = gate4agent_types::TerminalSize {
26002 rows: 24,
26003 columns: 80,
26004 };
26005
26006 for request in [
26007 NodeRequest::Prompt {
26008 session: address.clone(),
26009 text: "prompt".to_owned(),
26010 },
26011 NodeRequest::Paste {
26012 session: address.clone(),
26013 text: "paste".to_owned(),
26014 },
26015 NodeRequest::Resume {
26021 session: address.clone(),
26022 terminal_size,
26023 initial_prompt: Some("resume".to_owned()),
26024 },
26025 ] {
26026 let error = process_request_inner(
26027 &shared,
26028 77,
26029 ClientRole::Operator,
26030 request,
26031 )
26032 .await
26033 .unwrap_err();
26034 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26035 }
26036
26037 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26038 assert_eq!(shared.handle.snapshot().revision, revision_before);
26039 assert_eq!(shared.current_sequence(), history_sequence_before);
26040 let bindings = shared
26041 .session_bindings
26042 .lock()
26043 .unwrap_or_else(|poisoned| poisoned.into_inner());
26044 let binding = bindings.get(&address.session.instance_id).unwrap();
26045 assert_eq!(binding.generation, address.session.generation);
26046 assert!(binding.pending_resume.is_none());
26047 assert!(binding.record_id.is_none());
26048 }
26049
26050 #[tokio::test]
26051 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26052 let catalog = active_registry().unwrap();
26053 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26054 let primary = WorkspaceConfig::new(
26055 WorkspaceId::new("primary").unwrap(),
26056 std::env::current_dir().unwrap(),
26057 )
26058 .unwrap();
26059 let shared = NodeShared::new(
26060 handle,
26061 "fixture-token".to_owned(),
26062 NodeId::new("node-1").unwrap(),
26063 vec![primary],
26064 vec![agent("claude")],
26065 );
26066 shared
26067 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26068 .unwrap();
26069 let before = shared.snapshot();
26070 let error = process_request_inner(
26071 &shared,
26072 77,
26073 ClientRole::Operator,
26074 NodeRequest::RegisterWorkspace {
26075 workspace_id: WorkspaceId::new("foreign").unwrap(),
26076 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26077 },
26078 )
26079 .await
26080 .unwrap_err();
26081 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26082 assert_eq!(error.message, "invalid-request");
26083 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26084 }
26085
26086 #[tokio::test]
26087 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26088 let catalog = active_registry().unwrap();
26089 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26090 let primary_id = WorkspaceId::new("primary").unwrap();
26091 let primary = WorkspaceConfig::new(
26092 primary_id.clone(),
26093 std::env::current_dir().unwrap(),
26094 )
26095 .unwrap();
26096 let shared = NodeShared::new(
26097 handle,
26098 "fixture-token".to_owned(),
26099 NodeId::new("node-1").unwrap(),
26100 vec![primary.clone()],
26101 vec![agent("claude")],
26102 );
26103 let secondary_id = WorkspaceId::new("secondary").unwrap();
26104 let secondary = shared
26105 .register_workspace(
26106 secondary_id.clone(),
26107 std::env::temp_dir().to_string_lossy().into_owned(),
26108 )
26109 .await
26110 .unwrap();
26111 assert_eq!(secondary.workspace_id, secondary_id);
26112 assert!(secondary.sessions.is_empty());
26113 assert_eq!(shared.snapshot().workspaces.len(), 2);
26114
26115 let duplicate_id = shared
26116 .register_workspace(
26117 secondary_id.clone(),
26118 primary.canonical_root().to_owned(),
26119 )
26120 .await
26121 .unwrap_err();
26122 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26123 let duplicate_root = shared
26124 .register_workspace(
26125 WorkspaceId::new("duplicate-root").unwrap(),
26126 windows_path_text(&secondary.canonical_root).to_owned(),
26127 )
26128 .await
26129 .unwrap_err();
26130 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26131
26132 let address = SessionAddress {
26133 workspace_id: secondary_id.clone(),
26134 session: SessionKey {
26135 instance_id: AgentInstanceId(41),
26136 generation: SessionGeneration(1),
26137 },
26138 };
26139 shared.bind_session(
26140 address.session.instance_id,
26141 secondary_id.clone(),
26142 address.session.generation,
26143 );
26144 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26145 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26146 shared.remove_binding(&address);
26147 shared.unregister_workspace(&secondary_id).unwrap();
26148 assert_eq!(shared.snapshot().workspaces.len(), 1);
26149
26150 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26151 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26152 let history = shared
26153 .history
26154 .lock()
26155 .unwrap_or_else(|poisoned| poisoned.into_inner());
26156 assert!(matches!(
26157 &history.events[0],
26158 NodeEventEnvelope {
26159 sequence: 1,
26160 event: NodeEvent::WorkspaceAdded { workspace },
26161 } if workspace.workspace_id == secondary_id
26162 ));
26163 assert!(matches!(
26164 &history.events[1],
26165 NodeEventEnvelope {
26166 sequence: 2,
26167 event: NodeEvent::WorkspaceRemoved { workspace_id },
26168 } if workspace_id == &secondary_id
26169 ));
26170 }
26171
26172 #[tokio::test]
26173 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26174 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26175 std::fs::create_dir_all(&secondary_root).unwrap();
26176 let catalog = active_registry().unwrap();
26177 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26178 let primary_id = WorkspaceId::new("primary").unwrap();
26179 let secondary_id = WorkspaceId::new("secondary").unwrap();
26180 let shared = NodeShared::new(
26181 handle,
26182 "fixture-token".to_owned(),
26183 NodeId::new("node-session-reference-index").unwrap(),
26184 vec![
26185 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26186 .unwrap(),
26187 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26188 ],
26189 vec![agent("claude"), agent("codex")],
26190 );
26191 let identity = ProviderSessionIdentity {
26192 key: gate4agent_types::ProviderSessionKey::SessionId,
26193 id: "provider-session-42".to_owned(),
26194 transcript_path: None,
26195 };
26196 let index_request = |workspace_id: WorkspaceId,
26197 provider: AgentId,
26198 identity: ProviderSessionIdentity,
26199 display_name: &str| {
26200 NodeRequest::IndexProviderSession {
26201 workspace_id,
26202 provider,
26203 identity,
26204 display_name: display_name.to_owned(),
26205 }
26206 };
26207 let request = index_request(
26208 primary_id.clone(),
26209 agent("claude"),
26210 identity.clone(),
26211 "release shepherd",
26212 );
26213 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26214 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26215 }));
26216 let observer = process_request_inner(
26217 &shared,
26218 77,
26219 ClientRole::Observer,
26220 request.clone(),
26221 )
26222 .await
26223 .unwrap_err();
26224 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26225 let uncontrolled = process_request_inner(
26226 &shared,
26227 77,
26228 ClientRole::Operator,
26229 request.clone(),
26230 )
26231 .await
26232 .unwrap_err();
26233 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26234 shared
26235 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26236 .unwrap();
26237
26238 let indexed = process_request_inner(
26239 &shared,
26240 77,
26241 ClientRole::Operator,
26242 request.clone(),
26243 )
26244 .await
26245 .unwrap();
26246 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26247 panic!("provider session reference index returned another response");
26248 };
26249 assert_eq!(record.state, ManagedSessionState::Dormant);
26250 assert_eq!(record.mode, SessionMode::Pty);
26251 assert_eq!(record.workspace_id, primary_id);
26252 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26253 assert!(record.active_session.is_none());
26254 assert!(record.environment_profile.is_none());
26255 assert!(record.bundle.is_none());
26256 assert!(record.context.is_none());
26257
26258 let repeated = process_request_inner(
26259 &shared,
26260 77,
26261 ClientRole::Operator,
26262 index_request(
26263 primary_id.clone(),
26264 agent("claude"),
26265 identity.clone(),
26266 "ignored replacement",
26267 ),
26268 )
26269 .await
26270 .unwrap();
26271 assert!(matches!(
26272 repeated,
26273 NodeResponse::ProviderSessionIndexed { record: ref existing }
26274 if existing.record_id == record.record_id
26275 && existing.display_name == "release shepherd"
26276 ));
26277 let conflicting_workspace = process_request_inner(
26278 &shared,
26279 77,
26280 ClientRole::Operator,
26281 index_request(
26282 secondary_id,
26283 agent("claude"),
26284 identity.clone(),
26285 "release shepherd",
26286 ),
26287 )
26288 .await
26289 .unwrap_err();
26290 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26291 let conflicting_provider = process_request_inner(
26292 &shared,
26293 77,
26294 ClientRole::Operator,
26295 index_request(
26296 primary_id.clone(),
26297 agent("codex"),
26298 identity.clone(),
26299 "release shepherd",
26300 ),
26301 )
26302 .await
26303 .unwrap_err();
26304 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26305 let transcript_rejected = process_request_inner(
26306 &shared,
26307 77,
26308 ClientRole::Operator,
26309 index_request(
26310 primary_id,
26311 agent("claude"),
26312 ProviderSessionIdentity {
26313 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26314 ..identity
26315 },
26316 "release shepherd",
26317 ),
26318 )
26319 .await
26320 .unwrap_err();
26321 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26322 let upserts = shared
26323 .history
26324 .lock()
26325 .unwrap_or_else(|poisoned| poisoned.into_inner())
26326 .events
26327 .iter()
26328 .filter(|event| matches!(
26329 &event.event,
26330 NodeEvent::SessionRecordUpserted { record: published }
26331 if published.record_id == record.record_id
26332 ))
26333 .count();
26334 assert_eq!(upserts, 1);
26335 std::fs::remove_dir_all(secondary_root).unwrap();
26336 }
26337
26338 #[tokio::test]
26339 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26340 let fixture_root = temporary_workspace_root("standalone-independent");
26341 let target = fixture_root.join("independent");
26342 std::fs::create_dir_all(&fixture_root).unwrap();
26343 let catalog = active_registry().unwrap();
26344 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26345 let selected = WorkspaceConfig::new(
26346 WorkspaceId::new("primary").unwrap(),
26347 std::env::current_dir().unwrap(),
26348 )
26349 .unwrap();
26350 let selected_root = selected.canonical_root().to_owned();
26351 let shared = NodeShared::new(
26352 handle,
26353 "fixture-token".to_owned(),
26354 NodeId::new("node-standalone").unwrap(),
26355 vec![selected],
26356 vec![agent("claude")],
26357 );
26358 let workspace_id = WorkspaceId::new("independent").unwrap();
26359 let request = NodeRequest::CreateStandaloneWorkspace {
26360 workspace_id: workspace_id.clone(),
26361 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26362 initial_branch: Some("main".to_owned()),
26363 };
26364 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26365 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26366 }));
26367 assert!(!request_uses_unnegotiated_capability(
26368 &request,
26369 &baseline_capabilities().unwrap(),
26370 ));
26371 assert!(request_uses_unnegotiated_capability(&request, &[]));
26372
26373 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26374 .await
26375 .unwrap_err();
26376 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26377 assert!(!target.exists());
26378 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26379 .await
26380 .unwrap_err();
26381 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26382 assert!(!target.exists());
26383 shared
26384 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26385 .unwrap();
26386
26387 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26388 .await
26389 .unwrap();
26390 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26391 panic!("standalone workspace creation returned another response");
26392 };
26393 assert_eq!(workspace.workspace_id, workspace_id);
26394 assert!(target.join(".git").is_dir());
26395 let branch = run_git_bounded(
26396 target.to_str().unwrap(),
26397 &["branch", "--show-current"],
26398 GIT_OUTPUT_MAX_BYTES,
26399 )
26400 .await
26401 .unwrap();
26402 assert!(branch.success);
26403 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26404 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26405 assert_eq!(worktrees.len(), 1);
26406 let listed_root = WorkspaceConfig::new(
26407 WorkspaceId::new("listed-independent").unwrap(),
26408 &worktrees[0].path,
26409 )
26410 .unwrap();
26411 assert!(platform::roots_equal(
26412 listed_root.canonical_root(),
26413 windows_path_text(&workspace.canonical_root),
26414 ));
26415 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26416 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26417 snapshot.workspace_id == workspace_id
26418 && snapshot.canonical_root == workspace.canonical_root
26419 }));
26420 assert!(shared
26421 .history
26422 .lock()
26423 .unwrap_or_else(|poisoned| poisoned.into_inner())
26424 .events
26425 .iter()
26426 .any(|event| matches!(
26427 &event.event,
26428 NodeEvent::WorkspaceAdded { workspace }
26429 if workspace.workspace_id == workspace_id
26430 )));
26431 std::fs::remove_dir_all(fixture_root).unwrap();
26432 }
26433
26434 #[tokio::test]
26435 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26436 let fixture_root = temporary_workspace_root("standalone-compensation");
26437 let created_target = fixture_root.join("created-target");
26438 let existing_target = fixture_root.join("existing-target");
26439 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26440 let invalid_state_path = invalid_state_parent.join("state.json");
26441 std::fs::create_dir_all(&existing_target).unwrap();
26442 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26443 let catalog = active_registry().unwrap();
26444 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26445 let primary = WorkspaceConfig::new(
26446 WorkspaceId::new("primary").unwrap(),
26447 std::env::current_dir().unwrap(),
26448 )
26449 .unwrap();
26450 let mut shared = NodeShared::new(
26451 handle,
26452 "fixture-token".to_owned(),
26453 NodeId::new("node-standalone-compensation").unwrap(),
26454 vec![primary],
26455 vec![agent("claude")],
26456 );
26457 shared.state_path = Some(invalid_state_path);
26458
26459 let created_recovery = shared
26460 .create_standalone_workspace(
26461 WorkspaceId::new("compensated").unwrap(),
26462 created_target.to_string_lossy().into_owned(),
26463 Some("main".to_owned()),
26464 )
26465 .await
26466 .unwrap_err();
26467 assert_eq!(
26468 created_recovery.code,
26469 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26470 );
26471 assert!(created_target.join(".git").is_dir());
26472 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26473 workspace.workspace_id.as_str() != "compensated"
26474 }));
26475
26476 let existing_recovery = shared
26477 .create_standalone_workspace(
26478 WorkspaceId::new("restored").unwrap(),
26479 existing_target.to_string_lossy().into_owned(),
26480 None,
26481 )
26482 .await
26483 .unwrap_err();
26484 assert_eq!(
26485 existing_recovery.code,
26486 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26487 );
26488 assert!(existing_target.is_dir());
26489 assert!(existing_target.join(".git").is_dir());
26490 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26491 workspace.workspace_id.as_str() != "restored"
26492 }));
26493
26494 let registered_empty = fixture_root.join("registered-empty");
26495 std::fs::create_dir(®istered_empty).unwrap();
26496 let catalog = active_registry().unwrap();
26497 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26498 let registered = WorkspaceConfig::new(
26499 WorkspaceId::new("registered-empty").unwrap(),
26500 ®istered_empty,
26501 )
26502 .unwrap();
26503 let duplicate_shared = NodeShared::new(
26504 handle,
26505 "fixture-token".to_owned(),
26506 NodeId::new("node-standalone-duplicate-root").unwrap(),
26507 vec![registered],
26508 vec![agent("claude")],
26509 );
26510 let duplicate = duplicate_shared
26511 .create_standalone_workspace(
26512 WorkspaceId::new("duplicate-root").unwrap(),
26513 registered_empty.to_string_lossy().into_owned(),
26514 None,
26515 )
26516 .await
26517 .unwrap_err();
26518 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26519 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26520 std::fs::remove_dir_all(fixture_root).unwrap();
26521 }
26522
26523 #[test]
26524 fn managed_record_id_collision_preserves_the_existing_record() {
26525 let catalog = active_registry().unwrap();
26526 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26527 let workspace = WorkspaceConfig::new(
26528 WorkspaceId::new("primary").unwrap(),
26529 std::env::current_dir().unwrap(),
26530 )
26531 .unwrap();
26532 let shared = NodeShared::new(
26533 handle,
26534 "fixture-token".to_owned(),
26535 NodeId::new("node-1").unwrap(),
26536 vec![workspace.clone()],
26537 vec![agent("claude")],
26538 );
26539 let record_id = SessionRecordId::new("sr-collision").unwrap();
26540 let original = ManagedSessionRecord {
26541 record_id: record_id.clone(),
26542 display_name: "original".to_owned(),
26543 provider: agent("claude"),
26544 mode: SessionMode::Pty,
26545 state: ManagedSessionState::Dormant,
26546 workspace_id: workspace.workspace_id,
26547 canonical_root: opaque_windows_path(workspace.canonical_root),
26548 provider_session: None,
26549 active_session: None,
26550 environment_profile: None,
26551 bundle: None,
26552 context_id: None,
26553 context: None,
26554 exported_context: None,
26555 task_binding: None,
26556 created_at_unix_ms: 1,
26557 updated_at_unix_ms: 1,
26558 last_error: None,
26559 };
26560 shared.insert_record(original.clone()).unwrap();
26561 let mut collision = original.clone();
26562 collision.display_name = "replacement".to_owned();
26563 let error = shared.insert_record(collision).unwrap_err();
26564 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26565 assert_eq!(shared.record(&record_id).unwrap(), original);
26566 }
26567
26568 #[test]
26569 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26570 let catalog = active_registry().unwrap();
26571 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26572 let workspace = WorkspaceConfig::new(
26573 WorkspaceId::new("primary").unwrap(),
26574 std::env::current_dir().unwrap(),
26575 )
26576 .unwrap();
26577 let shared = NodeShared::new(
26578 handle,
26579 "fixture-token".to_owned(),
26580 NodeId::new("node-1").unwrap(),
26581 vec![workspace.clone()],
26582 vec![agent("claude")],
26583 );
26584 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26585 shared
26586 .insert_record(ManagedSessionRecord {
26587 record_id: record_id.clone(),
26588 display_name: "runtime error".to_owned(),
26589 provider: agent("claude"),
26590 mode: SessionMode::Pty,
26591 state: ManagedSessionState::IdentityPending,
26592 workspace_id: workspace.workspace_id,
26593 canonical_root: opaque_windows_path(workspace.canonical_root),
26594 provider_session: None,
26595 active_session: None,
26596 environment_profile: None,
26597 bundle: None,
26598 context_id: None,
26599 context: None,
26600 exported_context: None,
26601 task_binding: None,
26602 created_at_unix_ms: 1,
26603 updated_at_unix_ms: 1,
26604 last_error: None,
26605 })
26606 .unwrap();
26607
26608 shared
26609 .mark_record_error(
26610 &record_id,
26611 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26612 )
26613 .unwrap();
26614 let record = shared.record(&record_id).unwrap();
26615 assert_eq!(record.state, ManagedSessionState::Unavailable);
26616 assert_eq!(
26617 record.last_error.as_deref(),
26618 Some("provider-runtime-failed"),
26619 );
26620 let snapshot = shared.snapshot();
26621 assert_eq!(snapshot.session_records, vec![record]);
26622 }
26623
26624 #[test]
26633 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26634 let catalog = active_registry().unwrap();
26635 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26636 let workspace = WorkspaceConfig::new(
26637 WorkspaceId::new("primary").unwrap(),
26638 std::env::current_dir().unwrap(),
26639 )
26640 .unwrap();
26641 let shared = NodeShared::new(
26642 handle,
26643 "fixture-token".to_owned(),
26644 NodeId::new("node-1").unwrap(),
26645 vec![workspace],
26646 vec![agent("claude")],
26647 );
26648
26649 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26650
26651 shared.record_rejected_command(
26652 CommandId(1),
26653 "agent 'claude' does not support transport Acp".to_owned(),
26654 true,
26655 );
26656 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26658
26659 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26660 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26661 assert!(reason.unsupported_transport);
26662
26663 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26665
26666 shared.record_rejected_command(
26671 CommandId(3),
26672 "backend busy".to_owned(),
26673 false,
26674 );
26675 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26676 assert!(!unclassified.unsupported_transport);
26677 }
26678
26679 #[test]
26680 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26681 let catalog = active_registry().unwrap();
26682 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26683 let workspace = WorkspaceConfig::new(
26684 WorkspaceId::new("primary").unwrap(),
26685 std::env::current_dir().unwrap(),
26686 )
26687 .unwrap();
26688 let shared = NodeShared::new(
26689 handle,
26690 "fixture-token".to_owned(),
26691 NodeId::new("node-identity-policy").unwrap(),
26692 vec![workspace.clone()],
26693 vec![agent("claude")],
26694 );
26695 let address = SessionAddress {
26696 workspace_id: workspace.workspace_id.clone(),
26697 session: SessionKey {
26698 instance_id: AgentInstanceId(90),
26699 generation: SessionGeneration(1),
26700 },
26701 };
26702 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26703 shared
26704 .insert_record(ManagedSessionRecord {
26705 record_id: record_id.clone(),
26706 display_name: "policy denied".to_owned(),
26707 provider: agent("claude"),
26708 mode: SessionMode::Pty,
26709 state: ManagedSessionState::IdentityPending,
26710 workspace_id: workspace.workspace_id,
26711 canonical_root: opaque_windows_path(workspace.canonical_root),
26712 provider_session: None,
26713 active_session: Some(address.clone()),
26714 environment_profile: None,
26715 bundle: None,
26716 context_id: None,
26717 context: None,
26718 exported_context: None,
26719 task_binding: None,
26720 created_at_unix_ms: 1,
26721 updated_at_unix_ms: 1,
26722 last_error: None,
26723 })
26724 .unwrap();
26725 let event = ControlEvent {
26726 sequence: 1,
26727 command_id: None,
26728 instance_id: address.session.instance_id,
26729 generation: address.session.generation,
26730 event: ControlEventKind::ProviderEvent {
26731 sequence: 1,
26732 source: gate4agent_types::ProviderSource {
26733 family: AdapterFamily::PtySemantic,
26734 binding: AdapterBinding::new(
26735 gate4agent_types::AdapterId::new("claude").unwrap(),
26736 "fixture/v1",
26737 gate4agent_types::AdapterVerification::SyntheticFixture,
26738 )
26739 .unwrap(),
26740 },
26741 source_sequence: 1,
26742 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26743 identity: gate4agent_types::ProviderSessionIdentity {
26744 key: gate4agent_types::ProviderSessionKey::SessionId,
26745 id: "provider-session".to_owned(),
26746 transcript_path: None,
26747 },
26748 },
26749 },
26750 };
26751
26752 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26753
26754 let record = shared.record(&record_id).unwrap();
26755 assert!(record.provider_session.is_none());
26756 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26757 assert_eq!(record.active_session.as_ref(), Some(&address));
26758 }
26759
26760 #[test]
26761 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26762 let secondary_root = temporary_workspace_root("identity-scope");
26763 std::fs::create_dir_all(&secondary_root).unwrap();
26764 let catalog = active_registry().unwrap();
26765 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26766 let primary = WorkspaceConfig::new(
26767 WorkspaceId::new("primary").unwrap(),
26768 std::env::current_dir().unwrap(),
26769 )
26770 .unwrap();
26771 let secondary = WorkspaceConfig::new(
26772 WorkspaceId::new("secondary").unwrap(),
26773 &secondary_root,
26774 )
26775 .unwrap();
26776 let shared = NodeShared::new(
26777 handle,
26778 "fixture-token".to_owned(),
26779 NodeId::new("node-1").unwrap(),
26780 vec![primary.clone(), secondary.clone()],
26781 vec![agent("claude")],
26782 );
26783 let identity = gate4agent_types::ProviderSessionIdentity {
26784 key: gate4agent_types::ProviderSessionKey::SessionId,
26785 id: "provider-session".to_owned(),
26786 transcript_path: None,
26787 };
26788 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26789 shared
26790 .insert_record(ManagedSessionRecord {
26791 record_id: dormant_id.clone(),
26792 display_name: "dormant".to_owned(),
26793 provider: agent("claude"),
26794 mode: SessionMode::Pty,
26795 state: ManagedSessionState::Dormant,
26796 workspace_id: primary.workspace_id,
26797 canonical_root: opaque_windows_path(primary.canonical_root),
26798 provider_session: Some(identity.clone()),
26799 active_session: None,
26800 environment_profile: None,
26801 bundle: None,
26802 context_id: None,
26803 context: None,
26804 exported_context: None,
26805 task_binding: None,
26806 created_at_unix_ms: 1,
26807 updated_at_unix_ms: 1,
26808 last_error: None,
26809 })
26810 .unwrap();
26811 let address = SessionAddress {
26812 workspace_id: secondary.workspace_id.clone(),
26813 session: SessionKey {
26814 instance_id: AgentInstanceId(91),
26815 generation: SessionGeneration(1),
26816 },
26817 };
26818 let current_id = SessionRecordId::new("sr-current").unwrap();
26819 shared
26820 .insert_record(ManagedSessionRecord {
26821 record_id: current_id.clone(),
26822 display_name: "current".to_owned(),
26823 provider: agent("claude"),
26824 mode: SessionMode::Pty,
26825 state: ManagedSessionState::IdentityPending,
26826 workspace_id: secondary.workspace_id,
26827 canonical_root: opaque_windows_path(secondary.canonical_root),
26828 provider_session: None,
26829 active_session: Some(address.clone()),
26830 environment_profile: None,
26831 bundle: None,
26832 context_id: None,
26833 context: None,
26834 exported_context: None,
26835 task_binding: None,
26836 created_at_unix_ms: 2,
26837 updated_at_unix_ms: 2,
26838 last_error: None,
26839 })
26840 .unwrap();
26841 shared.bind_managed_session(
26842 &address,
26843 current_id.clone(),
26844 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26845 None,
26846 );
26847 let event = ControlEvent {
26848 sequence: 1,
26849 command_id: None,
26850 instance_id: address.session.instance_id,
26851 generation: address.session.generation,
26852 event: ControlEventKind::ProviderEvent {
26853 sequence: 1,
26854 source: gate4agent_types::ProviderSource {
26855 family: gate4agent_types::AdapterFamily::PtySemantic,
26856 binding: gate4agent_types::AdapterBinding::new(
26857 gate4agent_types::AdapterId::new("claude").unwrap(),
26858 "fixture/v1",
26859 gate4agent_types::AdapterVerification::SyntheticFixture,
26860 )
26861 .unwrap(),
26862 },
26863 source_sequence: 1,
26864 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26865 identity,
26866 },
26867 },
26868 };
26869 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26870
26871 let binding = shared
26872 .session_bindings
26873 .lock()
26874 .unwrap_or_else(|poisoned| poisoned.into_inner())
26875 .get(&address.session.instance_id)
26876 .cloned()
26877 .unwrap();
26878 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26879 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26880 let current = shared.record(¤t_id).unwrap();
26881 assert_eq!(current.state, ManagedSessionState::Unavailable);
26882 assert_eq!(current.active_session.as_ref(), Some(&address));
26883 assert_eq!(
26884 current.last_error.as_deref(),
26885 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26886 );
26887 std::fs::remove_dir_all(secondary_root).unwrap();
26888 }
26889
26890 #[tokio::test]
26891 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26892 let root = temporary_workspace_root("semantic-provider-session");
26893 std::fs::create_dir_all(&root).unwrap();
26894 let state_path = root.join("state-v1.json");
26895 let catalog = active_registry().unwrap();
26896 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26897 let workspace = WorkspaceConfig::new(
26898 WorkspaceId::new("primary").unwrap(),
26899 &root,
26900 )
26901 .unwrap();
26902 let node_id = NodeId::new("node-semantic-identity").unwrap();
26903 let shared = NodeShared::new_with_incarnation(
26904 handle,
26905 "fixture-token".to_owned(),
26906 node_id.clone(),
26907 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26908 vec![workspace.clone()],
26909 vec![agent("claude")],
26910 ProviderRuntimeStatuses::default(),
26911 None,
26912 Vec::new(),
26913 Vec::new(),
26914 SpawnProfileRegistry::default(),
26915 None,
26916 None,
26917 None,
26918 Some(state_path.clone()),
26919 Vec::new(),
26920 Vec::new(),
26921 Vec::new(),
26922 Vec::new(),
26923 None,
26924 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26925 );
26926 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26927 key: gate4agent_types::ProviderSessionKey::SessionId,
26928 id: "provider-session".to_owned(),
26929 transcript_path: None,
26930 };
26931 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26932 shared
26933 .insert_record(ManagedSessionRecord {
26934 record_id: durable_id.clone(),
26935 display_name: "durable".to_owned(),
26936 provider: agent("claude"),
26937 mode: SessionMode::Pty,
26938 state: ManagedSessionState::Dormant,
26939 workspace_id: workspace.workspace_id.clone(),
26940 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26941 provider_session: Some(persisted_identity.clone()),
26942 active_session: None,
26943 environment_profile: None,
26944 bundle: None,
26945 context_id: None,
26946 context: None,
26947 exported_context: None,
26948 task_binding: None,
26949 created_at_unix_ms: 1,
26950 updated_at_unix_ms: 1,
26951 last_error: None,
26952 })
26953 .unwrap();
26954 let address = SessionAddress {
26955 workspace_id: workspace.workspace_id.clone(),
26956 session: SessionKey {
26957 instance_id: AgentInstanceId(92),
26958 generation: SessionGeneration(2),
26959 },
26960 };
26961 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26962 shared
26963 .insert_record(ManagedSessionRecord {
26964 record_id: pending_id.clone(),
26965 display_name: "pending".to_owned(),
26966 provider: agent("claude"),
26967 mode: SessionMode::Pty,
26968 state: ManagedSessionState::IdentityPending,
26969 workspace_id: workspace.workspace_id.clone(),
26970 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26971 provider_session: None,
26972 active_session: Some(address.clone()),
26973 environment_profile: None,
26974 bundle: None,
26975 context_id: None,
26976 context: None,
26977 exported_context: None,
26978 task_binding: None,
26979 created_at_unix_ms: 2,
26980 updated_at_unix_ms: 2,
26981 last_error: None,
26982 })
26983 .unwrap();
26984 shared.bind_managed_session(
26985 &address,
26986 pending_id.clone(),
26987 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26988 None,
26989 );
26990 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26991 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26992 ..persisted_identity
26993 };
26994 let event = ControlEvent {
26995 sequence: 1,
26996 command_id: None,
26997 instance_id: address.session.instance_id,
26998 generation: address.session.generation,
26999 event: ControlEventKind::ProviderEvent {
27000 sequence: 1,
27001 source: gate4agent_types::ProviderSource {
27002 family: gate4agent_types::AdapterFamily::PtySemantic,
27003 binding: gate4agent_types::AdapterBinding::new(
27004 gate4agent_types::AdapterId::new("claude").unwrap(),
27005 "fixture/v1",
27006 gate4agent_types::AdapterVerification::SyntheticFixture,
27007 )
27008 .unwrap(),
27009 },
27010 source_sequence: 1,
27011 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27012 identity: observed_identity.clone(),
27013 },
27014 },
27015 };
27016 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27017
27018 let snapshot = shared.snapshot();
27019 assert_eq!(snapshot.session_records.len(), 1);
27020 let live = &snapshot.session_records[0];
27021 assert_eq!(live.record_id, durable_id);
27022 assert_eq!(live.state, ManagedSessionState::Live);
27023 assert_eq!(live.active_session.as_ref(), Some(&address));
27024 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27025 let binding = shared
27026 .session_bindings
27027 .lock()
27028 .unwrap_or_else(|poisoned| poisoned.into_inner())
27029 .get(&address.session.instance_id)
27030 .cloned()
27031 .unwrap();
27032 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27033
27034 let mut duplicate = live.clone();
27035 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27036 duplicate.display_name = "duplicate".to_owned();
27037 duplicate.state = ManagedSessionState::Dormant;
27038 duplicate.active_session = None;
27039 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27040 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27041 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27042 assert_eq!(shared.snapshot().session_records.len(), 1);
27043
27044 let terminal_size = gate4agent_types::TerminalSize {
27045 rows: 24,
27046 columns: 80,
27047 };
27048 let (first_busy, second_busy) = tokio::join!(
27049 shared.resume_session_record(&durable_id, terminal_size, None),
27050 shared.resume_session_record(&durable_id, terminal_size, None),
27051 );
27052 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27053 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27054 assert_eq!(busy.message, "session-record-busy");
27055 }
27056
27057 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27058 assert_eq!(loaded.records.len(), 1);
27059 assert_eq!(loaded.records[0].record_id, durable_id);
27060 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27061 assert_eq!(
27062 loaded.records[0]
27063 .provider_session
27064 .as_ref()
27065 .unwrap()
27066 .transcript_path,
27067 None,
27068 );
27069 let catalog = active_registry().unwrap();
27070 let (reloaded_handle, _reloaded_runtime) =
27071 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27072 let reloaded = NodeShared::new_with_incarnation(
27073 reloaded_handle,
27074 "fixture-token".to_owned(),
27075 node_id,
27076 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27077 vec![workspace],
27078 vec![agent("claude")],
27079 ProviderRuntimeStatuses::default(),
27080 None,
27081 Vec::new(),
27082 Vec::new(),
27083 SpawnProfileRegistry::default(),
27084 None,
27085 None,
27086 None,
27087 None,
27088 loaded.records,
27089 loaded.managed_worktrees,
27090 loaded.managed_worktree_tombstones,
27091 loaded.materializations,
27092 loaded.warning,
27093 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27094 );
27095 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27096 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27097 std::fs::remove_dir_all(root).unwrap();
27098 }
27099
27100 #[tokio::test]
27101 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27102 let secondary_root = temporary_workspace_root("workspace-restore");
27103 let other_root = temporary_workspace_root("workspace-conflict");
27104 std::fs::create_dir_all(&secondary_root).unwrap();
27105 std::fs::create_dir_all(&other_root).unwrap();
27106 let catalog = active_registry().unwrap();
27107 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27108 let primary = WorkspaceConfig::new(
27109 WorkspaceId::new("primary").unwrap(),
27110 std::env::current_dir().unwrap(),
27111 )
27112 .unwrap();
27113 let secondary = WorkspaceConfig::new(
27114 WorkspaceId::new("secondary").unwrap(),
27115 &secondary_root,
27116 )
27117 .unwrap();
27118 let shared = NodeShared::new(
27119 handle,
27120 "fixture-token".to_owned(),
27121 NodeId::new("node-1").unwrap(),
27122 vec![primary, secondary.clone()],
27123 vec![agent("claude")],
27124 );
27125 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27126 shared
27127 .insert_record(ManagedSessionRecord {
27128 record_id: record_id.clone(),
27129 display_name: "workspace record".to_owned(),
27130 provider: agent("claude"),
27131 mode: SessionMode::Pty,
27132 state: ManagedSessionState::Dormant,
27133 workspace_id: secondary.workspace_id.clone(),
27134 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27135 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27136 key: gate4agent_types::ProviderSessionKey::SessionId,
27137 id: "provider-session".to_owned(),
27138 transcript_path: None,
27139 }),
27140 active_session: None,
27141 environment_profile: None,
27142 bundle: None,
27143 context_id: None,
27144 context: None,
27145 exported_context: None,
27146 task_binding: None,
27147 created_at_unix_ms: 1,
27148 updated_at_unix_ms: 1,
27149 last_error: None,
27150 })
27151 .unwrap();
27152 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27153 let unavailable = shared.record(&record_id).unwrap();
27154 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27155 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27156
27157 let conflict = shared
27158 .register_workspace(
27159 secondary.workspace_id.clone(),
27160 other_root.to_string_lossy().into_owned(),
27161 )
27162 .await
27163 .unwrap_err();
27164 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27165 shared
27166 .register_workspace(
27167 secondary.workspace_id,
27168 secondary_root.to_string_lossy().into_owned(),
27169 )
27170 .await
27171 .unwrap();
27172 let restored = shared.record(&record_id).unwrap();
27173 assert_eq!(restored.state, ManagedSessionState::Dormant);
27174 assert!(restored.last_error.is_none());
27175 std::fs::remove_dir_all(secondary_root).unwrap();
27176 std::fs::remove_dir_all(other_root).unwrap();
27177 }
27178
27179 #[test]
27180 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27181 let error = validate_workspace_request_root(
27182 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27183 )
27184 .unwrap_err();
27185 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27186 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27187 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27188 }
27189
27190 #[test]
27191 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27192 let catalog = active_registry().unwrap();
27193 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27194 let workspace = WorkspaceConfig::new(
27195 WorkspaceId::new("primary").unwrap(),
27196 std::env::current_dir().unwrap(),
27197 )
27198 .unwrap();
27199 let shared = NodeShared::new(
27200 handle,
27201 "fixture-token".to_owned(),
27202 NodeId::new("node-1").unwrap(),
27203 vec![workspace],
27204 vec![agent("claude")],
27205 );
27206 let publishers = 4_u64;
27207 let events_per_publisher = 256_u64;
27208 let start = std::sync::Barrier::new(publishers as usize + 1);
27209 let finished = AtomicU64::new(0);
27210
27211 std::thread::scope(|scope| {
27212 for _ in 0..publishers {
27213 scope.spawn(|| {
27214 start.wait();
27215 for _ in 0..events_per_publisher {
27216 shared.publish(NodeEvent::ControllerChanged { controller: None });
27217 }
27218 finished.fetch_add(1, Ordering::Release);
27219 });
27220 }
27221 start.wait();
27222 while finished.load(Ordering::Acquire) != publishers {
27223 assert_resync_history_is_linearized(shared.resync(0));
27224 std::thread::yield_now();
27225 }
27226 });
27227 let response = shared.resync(0);
27228 assert_resync_history_is_linearized(response.clone());
27229 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27230 unreachable!("resync helper returned another response");
27231 };
27232 assert_eq!(event_sequence, publishers * events_per_publisher);
27233 assert_eq!(events.len() as u64, event_sequence);
27234 }
27235
27236 fn assert_resync_history_is_linearized(response: NodeResponse) {
27237 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27238 unreachable!("resync helper returned another response");
27239 };
27240 assert!(events.windows(2).all(|pair| {
27241 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27242 }));
27243 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27244 }
27245
27246 #[test]
27247 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27248 assert_eq!(
27249 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27250 r"C:\repo\worktree",
27251 );
27252 assert_eq!(
27253 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27254 r"\\server\share\repo",
27255 );
27256 assert!(validate_workspace_root(
27257 &WorkspaceId::new("network").unwrap(),
27258 r"\\server\share\repo",
27259 )
27260 .is_err());
27261 let workspace = WorkspaceConfig::new(
27262 WorkspaceId::new("primary").unwrap(),
27263 std::env::current_dir().unwrap(),
27264 )
27265 .unwrap();
27266 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27267 }
27268
27269 #[test]
27270 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27271 let catalog = active_registry().unwrap();
27272 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27273 let workspace_id = WorkspaceId::new("primary").unwrap();
27274 let workspace = WorkspaceConfig::new(
27275 workspace_id.clone(),
27276 std::env::current_dir().unwrap(),
27277 )
27278 .unwrap();
27279 let shared = NodeShared::new(
27280 handle,
27281 "fixture-token".to_owned(),
27282 NodeId::new("node-1").unwrap(),
27283 vec![workspace],
27284 vec![agent("claude")],
27285 );
27286 let instance_id = AgentInstanceId(17);
27287 let original = SessionAddress {
27288 workspace_id,
27289 session: SessionKey {
27290 instance_id,
27291 generation: SessionGeneration(3),
27292 },
27293 };
27294 shared.bind_session(
27295 instance_id,
27296 original.workspace_id.clone(),
27297 original.session.generation,
27298 );
27299 shared
27300 .arm_resume(
27301 &original,
27302 CommandId(41),
27303 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27304 )
27305 .unwrap();
27306
27307 shared.publish_control(ControlEvent {
27308 sequence: 1,
27309 command_id: None,
27310 instance_id,
27311 generation: SessionGeneration(4),
27312 event: ControlEventKind::Running { process_id: None },
27313 });
27314 assert_eq!(
27315 shared.address_for(instance_id, SessionGeneration(3)),
27316 Some(original.clone()),
27317 );
27318 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27319
27320 shared.publish_control(ControlEvent {
27321 sequence: 2,
27322 command_id: Some(CommandId(42)),
27323 instance_id,
27324 generation: SessionGeneration(3),
27325 event: ControlEventKind::CommandRejected {
27326 message: "unrelated command".to_owned(),
27327 },
27328 });
27329
27330 shared.publish_control(ControlEvent {
27331 sequence: 3,
27332 command_id: None,
27333 instance_id,
27334 generation: SessionGeneration(4),
27335 event: ControlEventKind::ResumeAuthorized {
27336 session: gate4agent_types::ResumeSessionSummary {
27337 key: gate4agent_types::ProviderSessionKey::SessionId,
27338 id: "fixture-session".to_owned(),
27339 },
27340 },
27341 });
27342 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27343 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27344 assert_eq!(rebound.workspace_id, original.workspace_id);
27345 let history = shared
27346 .history
27347 .lock()
27348 .unwrap_or_else(|poisoned| poisoned.into_inner());
27349 assert!(history.events.iter().any(|envelope| matches!(
27350 &envelope.event,
27351 NodeEvent::Control { address, event }
27352 if address == &rebound
27353 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27354 )));
27355 }
27356
27357 #[test]
27358 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27359 for agent in ["claude", "codex", "kimi"] {
27360 assert_eq!(
27361 prompt_framing(&AgentId::new(agent).unwrap()),
27362 PromptFraming::BracketedPaste
27363 );
27364 }
27365 }
27366
27367 fn generous_walk_deadline() -> Instant {
27368 Instant::now() + Duration::from_secs(30)
27369 }
27370
27371 #[test]
27372 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27373 let root = temporary_workspace_root("tree");
27374 std::fs::create_dir_all(root.join("src")).unwrap();
27375 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27376 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27377 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27378 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27379 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27380 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27386 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27387 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27391 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27392
27393 let (entries, truncated, _budget) =
27394 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27395 assert!(!truncated);
27396 assert!(entries.iter().any(|entry| {
27397 entry.relative_path.as_utf8() == Some("src")
27398 && entry.kind == WorkspaceEntryKind::Directory
27399 }));
27400 assert!(entries.iter().any(|entry| {
27401 entry.relative_path.as_utf8() == Some("src/lib.rs")
27402 && entry.kind == WorkspaceEntryKind::File
27403 }));
27404 assert!(entries.iter().any(|entry| {
27405 entry.relative_path.as_utf8() == Some("target-notes")
27406 && entry.kind == WorkspaceEntryKind::Directory
27407 }));
27408 assert!(entries.iter().any(|entry| {
27409 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27410 && entry.kind == WorkspaceEntryKind::File
27411 }));
27412 assert!(entries.iter().all(|entry| {
27413 let path = entry.relative_path.as_utf8().unwrap();
27414 !path.starts_with(".git")
27415 && !(path == "target" || path.starts_with("target/"))
27416 && !(path == "target-a2" || path.starts_with("target-a2/"))
27417 && !path.starts_with("node_modules")
27418 }));
27419
27420 std::fs::remove_dir_all(&root).unwrap();
27421 }
27422
27423 #[test]
27424 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27425 let root = temporary_workspace_root("tree-entry-cap");
27426 std::fs::create_dir_all(&root).unwrap();
27427 for index in 0..20 {
27428 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27429 }
27430
27431 let (entries, truncated, budget) =
27432 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27433 assert!(truncated);
27434 assert!(budget.entry_cap_exceeded);
27435 assert!(!budget.time_budget_exceeded);
27436 assert!(entries.len() <= 5);
27437
27438 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27439 .expect("an entry-cap-truncated walk must report a truncation marker");
27440 assert!(inspection_truncation.walk_entry_cap_exceeded);
27441 assert!(!inspection_truncation.walk_time_budget_exceeded);
27442 assert!(!inspection_truncation.git_time_budget_exceeded);
27443 assert!(inspection_truncation.entries_visited >= 5);
27444
27445 std::fs::remove_dir_all(&root).unwrap();
27446 }
27447
27448 #[test]
27449 fn workspace_walk_within_budget_reports_no_truncation() {
27450 let root = temporary_workspace_root("tree-no-truncation");
27451 std::fs::create_dir_all(&root).unwrap();
27452 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27453
27454 let (_entries, truncated, budget) =
27455 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27456 assert!(!truncated);
27457 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27458
27459 std::fs::remove_dir_all(&root).unwrap();
27460 }
27461
27462 #[test]
27463 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27464 let root = temporary_workspace_root("tree-prefix-siblings");
27465 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27466 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27467 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27468 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27469
27470 let (entries, truncated, _budget) =
27471 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27472 assert!(!truncated);
27473 let paths = entries
27474 .iter()
27475 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27476 .collect::<Vec<_>>();
27477 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27478 assert_eq!(
27479 paths,
27480 vec![
27481 "gate4agent-c2".to_owned(),
27482 "gate4agent-c2-client".to_owned(),
27483 "gate4agent-c2-client/lib.rs".to_owned(),
27484 "gate4agent-c2/src".to_owned(),
27485 "gate4agent-c2/src/lib.rs".to_owned(),
27486 ],
27487 );
27488
27489 std::fs::remove_dir_all(&root).unwrap();
27490 }
27491
27492 #[test]
27493 fn git_parsers_cap_status_and_commit_results() {
27494 let mut status_output = Vec::new();
27495 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27496 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27497 }
27498 let mut snapshot = git_snapshot_for_parser();
27499 parse_git_status(&status_output, &mut snapshot);
27500 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27501 assert!(snapshot.truncated);
27502 assert_eq!(snapshot.status[0].index_status, " ");
27503 assert_eq!(snapshot.status[0].worktree_status, "M");
27504
27505 snapshot.truncated = false;
27506 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27507 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27508 .collect::<String>();
27509 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27510 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27511 assert!(snapshot.truncated);
27512 }
27513
27514 #[test]
27515 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27516 let mut snapshot = git_snapshot_for_parser();
27517 parse_git_status(
27518 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27519 &mut snapshot,
27520 );
27521
27522 assert!(!snapshot.truncated);
27523 assert_eq!(snapshot.status.len(), 3);
27524 assert_eq!(snapshot.status[0].index_status, "R");
27525 assert_eq!(snapshot.status[0].worktree_status, " ");
27526 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27527 assert_eq!(
27528 snapshot.status[0]
27529 .previous_path
27530 .as_ref()
27531 .and_then(RepositoryPath::as_utf8),
27532 Some("src/previous name.rs"),
27533 );
27534 assert_eq!(snapshot.status[1].index_status, " ");
27535 assert_eq!(snapshot.status[1].worktree_status, "C");
27536 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27537 assert_eq!(
27538 snapshot.status[1]
27539 .previous_path
27540 .as_ref()
27541 .and_then(RepositoryPath::as_utf8),
27542 Some("src/original.rs"),
27543 );
27544 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27545 assert!(snapshot.status[2].previous_path.is_none());
27546 }
27547
27548 #[test]
27549 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27550 let mut snapshot = git_snapshot_for_parser();
27551 parse_git_status(
27552 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27553 &mut snapshot,
27554 );
27555
27556 assert!(snapshot.truncated);
27557 assert_eq!(snapshot.status.len(), 1);
27558 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27559 assert!(snapshot.status[0].previous_path.is_none());
27560 }
27561
27562 #[tokio::test]
27563 async fn observer_can_browse_host_directories_without_controller() {
27564 let root = temporary_workspace_root("host-directory-observer");
27565 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27566 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27567 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27568 let workspace_id = WorkspaceId::new("primary").unwrap();
27569 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27570 let catalog = active_registry().unwrap();
27571 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27572 let shared = NodeShared::new(
27573 handle,
27574 "fixture-token".to_owned(),
27575 NodeId::new("node-1").unwrap(),
27576 vec![workspace],
27577 vec![agent("claude")],
27578 );
27579 let request = NodeRequest::BrowseHostDirectories {
27580 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27581 after: None,
27582 };
27583 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27584 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27585 }));
27586 assert!(!request_uses_unnegotiated_capability(
27587 &request,
27588 &baseline_capabilities().unwrap(),
27589 ));
27590 assert!(request_uses_unnegotiated_capability(&request, &[]));
27591
27592 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27593 .await
27594 .unwrap();
27595 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27596 panic!("host directory browse returned another response");
27597 };
27598 assert_eq!(
27599 listing
27600 .entries
27601 .iter()
27602 .map(|entry| entry.display_name.as_str())
27603 .collect::<Vec<_>>(),
27604 vec!["directory-a", "directory-b"],
27605 );
27606 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27607 assert!(!listing.incomplete);
27608 std::fs::remove_dir_all(root).unwrap();
27609 }
27610
27611 #[tokio::test]
27612 async fn observer_can_inspect_only_a_registered_workspace() {
27613 let root = temporary_workspace_root("observer");
27614 std::fs::create_dir_all(root.join("src")).unwrap();
27615 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27616 let workspace_id = WorkspaceId::new("primary").unwrap();
27617 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27618 let catalog = active_registry().unwrap();
27619 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27620 let shared = NodeShared::new(
27621 handle,
27622 "fixture-token".to_owned(),
27623 NodeId::new("node-1").unwrap(),
27624 vec![workspace],
27625 vec![agent("claude")],
27626 );
27627
27628 let response = process_request_inner(
27629 &shared,
27630 77,
27631 ClientRole::Observer,
27632 NodeRequest::InspectWorkspace {
27633 workspace_id: workspace_id.clone(),
27634 },
27635 )
27636 .await
27637 .unwrap();
27638 let NodeResponse::WorkspaceInspected { inspection } = response else {
27639 panic!("workspace inspection returned another response");
27640 };
27641 assert_eq!(inspection.workspace_id, workspace_id);
27642 assert!(inspection.entries.iter().any(|entry| {
27643 entry.relative_path.as_utf8() == Some("src/main.rs")
27644 }));
27645
27646 let unknown = process_request_inner(
27647 &shared,
27648 77,
27649 ClientRole::Observer,
27650 NodeRequest::InspectWorkspace {
27651 workspace_id: WorkspaceId::new("unknown").unwrap(),
27652 },
27653 )
27654 .await
27655 .unwrap_err();
27656 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27657 std::fs::remove_dir_all(&root).unwrap();
27658 }
27659
27660 #[test]
27661 fn context_pack_source_accepts_only_running_or_successful_exit() {
27662 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27663 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27664 exit_code: Some(0),
27665 }));
27666 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27667 exit_code: Some(1),
27668 }));
27669 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27670 exit_code: None,
27671 }));
27672 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27673 message: "provider failed".to_owned(),
27674 }));
27675 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27676 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27677 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27678 }
27679
27680 #[test]
27681 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27682 let git = git_snapshot_for_parser();
27683 let first = ContextPackRepositoryObservation {
27684 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27685 git: git.clone(),
27686 files: Vec::new(),
27687 };
27688 let second = ContextPackRepositoryObservation {
27689 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27690 git,
27691 files: Vec::new(),
27692 };
27693
27694 let error = stable_context_pack_repository(first, second).unwrap_err();
27695
27696 assert_eq!(
27697 error.code,
27698 NodeFailureCode::ContextPackMaterializationFailed,
27699 );
27700 }
27701
27702 #[test]
27703 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27704 let git = git_snapshot_for_parser();
27705 let first = ContextPackRepositoryObservation {
27706 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27707 git: git.clone(),
27708 files: vec![ContextPackRepositoryFileSource::utf8(
27709 "README.md",
27710 "first\n".to_owned(),
27711 6,
27712 )],
27713 };
27714 let second = ContextPackRepositoryObservation {
27715 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27716 git,
27717 files: vec![ContextPackRepositoryFileSource::utf8(
27718 "README.md",
27719 "other\n".to_owned(),
27720 6,
27721 )],
27722 };
27723
27724 let error = stable_context_pack_repository(first, second).unwrap_err();
27725
27726 assert_eq!(
27727 error.code,
27728 NodeFailureCode::ContextPackMaterializationFailed,
27729 );
27730 }
27731
27732 #[tokio::test]
27733 async fn native_session_catalog_zero_active_session_fixture() {
27734 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27735 let history_root = temporary_workspace_root("native-catalog-history");
27736 let outside_root = temporary_workspace_root("native-catalog-outside");
27737 std::fs::create_dir_all(&workspace_root).unwrap();
27738 std::fs::create_dir_all(&history_root).unwrap();
27739 std::fs::create_dir_all(&outside_root).unwrap();
27740 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27741 [
27742 serde_json::json!({
27743 "type": "user",
27744 "sessionId": session_id,
27745 "cwd": cwd.to_str().unwrap(),
27746 "message": { "content": "raw user text must not cross the catalog wire" }
27747 }),
27748 serde_json::json!({
27749 "type": "assistant",
27750 "sessionId": session_id,
27751 "cwd": cwd.to_str().unwrap(),
27752 "message": { "model": "claude-sonnet", "content": "raw answer" }
27753 }),
27754 serde_json::json!({
27755 "type": "ai-title",
27756 "sessionId": session_id,
27757 "aiTitle": title
27758 }),
27759 ]
27760 .into_iter()
27761 .map(|record| record.to_string())
27762 .collect::<Vec<_>>()
27763 .join("\n")
27764 };
27765 std::fs::write(
27766 history_root.join("stable-session.jsonl"),
27767 transcript("stable-session", &workspace_root, "Workspace review"),
27768 )
27769 .unwrap();
27770 std::fs::write(
27771 history_root.join("outside-session.jsonl"),
27772 transcript("outside-session", &outside_root, "Outside review"),
27773 )
27774 .unwrap();
27775 let history = NativeHistoryConfig::new(vec![
27776 NativeHistoryRoot::new(
27777 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27778 HistorySourceLayout::SingleNdjson,
27779 history_root.clone(),
27780 )
27781 .unwrap(),
27782 ])
27783 .unwrap();
27784 let catalog = active_registry().unwrap();
27785 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27786 assert!(handle.snapshot().sessions.is_empty());
27787 let workspace_id = WorkspaceId::new("primary").unwrap();
27788 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27789 let mut shared = NodeShared::new(
27790 handle,
27791 "fixture-token".to_owned(),
27792 NodeId::new("node-native-catalog").unwrap(),
27793 vec![workspace],
27794 vec![agent("claude")],
27795 );
27796 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27797 NativeSessionCatalogAuthority::new(history),
27798 )));
27799 let route = NativeSessionCatalogRoute::workspace(
27800 workspace_id.clone(),
27801 agent("claude"),
27802 );
27803 let request = NodeRequest::CatalogNativeSessions {
27804 route: route.clone(),
27805 limit: 2,
27806 };
27807 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27808 .await
27809 .unwrap_err();
27810 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27811 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27812 .await
27813 .unwrap();
27814 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27815 panic!("native catalog returned a different response");
27816 };
27817 let summary = summary.expect("node must emit native session catalog summary metadata");
27818 assert_eq!(entries.len(), 1);
27819 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27820 assert_eq!(entries[0].external_group, None);
27821 assert_eq!(entries[0].record_id, None);
27822 assert_eq!(entries[0].title, None);
27823 assert_eq!(entries[0].model, None);
27824 assert_eq!(entries[0].message_count, 0);
27825 let encoded_entries = serde_json::to_string(&entries).unwrap();
27826 assert!(!encoded_entries.contains("stable-session"));
27827 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27828 let page_request = NodeRequest::PageNativeSessions {
27829 route: route.clone(),
27830 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27831 catalog_revision: summary.catalog_revision,
27832 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27833 after_selection_id: None,
27834 limit: 2,
27835 };
27836 let observer = process_request_inner(
27837 &shared,
27838 7,
27839 ClientRole::Observer,
27840 page_request.clone(),
27841 )
27842 .await
27843 .unwrap_err();
27844 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27845 let page_response = process_request_inner(
27846 &shared,
27847 7,
27848 ClientRole::Operator,
27849 page_request.clone(),
27850 )
27851 .await
27852 .unwrap();
27853 assert!(matches!(
27854 page_response,
27855 NodeResponse::NativeSessionsPaged { page, .. }
27856 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27857 ));
27858 let stale = process_request_inner(
27859 &shared,
27860 7,
27861 ClientRole::Operator,
27862 NodeRequest::PageNativeSessions {
27863 route: route.clone(),
27864 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27865 catalog_revision: summary.catalog_revision.wrapping_add(1),
27866 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27867 after_selection_id: None,
27868 limit: 2,
27869 },
27870 )
27871 .await
27872 .unwrap_err();
27873 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27874 let selection = NativeSessionSelection {
27875 route: route.clone(),
27876 catalog_revision: summary.catalog_revision,
27877 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27878 selection_id: entries[0].selection_id.clone(),
27879 };
27880 let preview_response = process_request_inner(
27881 &shared,
27882 7,
27883 ClientRole::Operator,
27884 NodeRequest::PreviewNativeSession {
27885 selection: selection.clone(),
27886 message_limit: 1,
27887 },
27888 )
27889 .await
27890 .unwrap();
27891 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27892 panic!("native preview returned a different response");
27893 };
27894 assert_eq!(preview.messages.len(), 1);
27895 assert!(preview.message_count_exact);
27896 assert_eq!(preview.messages[0].text, "raw answer");
27897 assert!(preview.truncated);
27898
27899 let index_request = NodeRequest::IndexNativeSession {
27900 selection: selection.clone(),
27901 display_name: "Saved Claude".to_owned(),
27902 };
27903 let observer = process_request_inner(
27904 &shared,
27905 7,
27906 ClientRole::Observer,
27907 index_request.clone(),
27908 )
27909 .await
27910 .unwrap_err();
27911 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27912 let without_controller = process_request_inner(
27913 &shared,
27914 7,
27915 ClientRole::Operator,
27916 index_request.clone(),
27917 )
27918 .await
27919 .unwrap_err();
27920 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27921 let acquired = process_request_inner(
27922 &shared,
27923 7,
27924 ClientRole::Operator,
27925 NodeRequest::AcquireController { lease_ms: 5_000 },
27926 )
27927 .await
27928 .unwrap();
27929 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27930 let indexed = process_request_inner(
27931 &shared,
27932 7,
27933 ClientRole::Operator,
27934 index_request,
27935 )
27936 .await
27937 .unwrap();
27938 assert!(indexed.native_session_index_contract_is_valid());
27939 let NodeResponse::NativeSessionIndexed {
27940 selection: echoed_selection,
27941 record,
27942 } = indexed else {
27943 panic!("native session index returned a different response");
27944 };
27945 assert_eq!(echoed_selection, selection);
27946 let recataloged = process_request_inner(
27947 &shared,
27948 7,
27949 ClientRole::Operator,
27950 NodeRequest::CatalogNativeSessions {
27951 route,
27952 limit: 2,
27953 },
27954 )
27955 .await
27956 .unwrap();
27957 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27958 panic!("native catalog returned a different response after indexing");
27959 };
27960 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27961
27962 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27963 let external_catalog = process_request_inner(
27964 &shared,
27965 7,
27966 ClientRole::Operator,
27967 NodeRequest::CatalogNativeSessions {
27968 route: unregistered_route.clone(),
27969 limit: 2,
27970 },
27971 )
27972 .await
27973 .unwrap();
27974 let external_json = serde_json::to_string(&external_catalog).unwrap();
27975 assert!(!external_json.contains("outside-session"));
27976 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27977 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27978 assert!(!external_json.contains("\"session_id\""));
27979 assert!(!external_json.contains("\"cwd\""));
27980 assert!(!external_json.contains("\"path\""));
27981 let NodeResponse::NativeSessionsCataloged {
27982 route: echoed_route,
27983 entries: external_entries,
27984 summary: Some(external_summary),
27985 } = external_catalog
27986 else {
27987 panic!("unregistered native catalog returned a different response");
27988 };
27989 assert_eq!(echoed_route, unregistered_route);
27990 assert_eq!(external_entries.len(), 1);
27991 assert_eq!(external_entries[0].record_id, None);
27992 let external_group = external_entries[0]
27993 .external_group
27994 .as_ref()
27995 .expect("unregistered session must have an external group");
27996 assert_eq!(
27997 external_group.kind,
27998 gate4agent_types::NativeSessionExternalGroupKind::Project,
27999 );
28000 assert!(external_group.group_id.starts_with("external-"));
28001 assert_eq!(
28002 external_group.display_name,
28003 outside_root.file_name().unwrap().to_string_lossy(),
28004 );
28005 let external_selection = NativeSessionSelection {
28006 route: unregistered_route,
28007 catalog_revision: external_summary.catalog_revision,
28008 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28009 selection_id: external_entries[0].selection_id.clone(),
28010 };
28011 let external_preview = process_request_inner(
28012 &shared,
28013 7,
28014 ClientRole::Operator,
28015 NodeRequest::PreviewNativeSession {
28016 selection: external_selection.clone(),
28017 message_limit: 1,
28018 },
28019 )
28020 .await
28021 .unwrap();
28022 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28023 assert!(!external_preview_json.contains("outside-session"));
28024 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28025 assert!(!external_preview_json.contains("\"session_id\""));
28026 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28027 panic!("unregistered native preview returned a different response");
28028 };
28029 assert_eq!(preview.messages.len(), 1);
28030 let external_index = process_request_inner(
28031 &shared,
28032 7,
28033 ClientRole::Operator,
28034 NodeRequest::IndexNativeSession {
28035 selection: external_selection,
28036 display_name: "External Claude".to_owned(),
28037 },
28038 )
28039 .await
28040 .unwrap_err();
28041 assert_eq!(
28042 external_index.code,
28043 NodeFailureCode::WorkspaceRegistrationRequired,
28044 );
28045 let record_response = process_request_inner(
28046 &shared,
28047 7,
28048 ClientRole::Operator,
28049 NodeRequest::PreviewSessionRecord {
28050 record_id: record.record_id.clone(),
28051 message_limit: 2,
28052 },
28053 )
28054 .await
28055 .unwrap();
28056 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28057 panic!("record preview returned a different response");
28058 };
28059 assert_eq!(record_id, record.record_id);
28060 assert_eq!(preview.messages.len(), 2);
28061 let encoded = serde_json::to_string(&preview).unwrap();
28062 assert!(!encoded.contains("stable-session"));
28063 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28064 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28065 panic!("record preview history summary returned a different response");
28066 };
28067 let summaries = events
28068 .iter()
28069 .filter_map(|envelope| match &envelope.event {
28070 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28071 if record_id == &record.record_id =>
28072 {
28073 Some(*summary)
28074 }
28075 _ => None,
28076 })
28077 .collect::<Vec<_>>();
28078 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28079 let summary_json = serde_json::to_string(&summaries).unwrap();
28080 for private in [
28081 "stable-session",
28082 "raw user text must not cross the catalog wire",
28083 "raw answer",
28084 "Workspace review",
28085 "claude-sonnet",
28086 ] {
28087 assert!(!summary_json.contains(private), "{private}");
28088 }
28089 let identity = record.provider_session.clone().unwrap();
28090 assert_eq!(
28091 shared.revalidate_session_record_preview(&record, &identity),
28092 Ok(())
28093 );
28094 shared.remove_record_memory(&record.record_id);
28095 assert_eq!(
28096 shared
28097 .revalidate_session_record_preview(&record, &identity)
28098 .unwrap_err()
28099 .code,
28100 NodeFailureCode::UnknownSessionRecord
28101 );
28102 let released = process_request_inner(
28103 &shared,
28104 7,
28105 ClientRole::Operator,
28106 NodeRequest::ReleaseController,
28107 )
28108 .await
28109 .unwrap();
28110 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28111 assert!(shared.controller_state().is_none());
28112 std::fs::remove_dir_all(workspace_root).unwrap();
28113 std::fs::remove_dir_all(history_root).unwrap();
28114 std::fs::remove_dir_all(outside_root).unwrap();
28115 }
28116
28117 #[tokio::test]
28118 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28119 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28120 std::fs::create_dir_all(&history_root).unwrap();
28121 let history = NativeHistoryConfig::new(vec![
28122 NativeHistoryRoot::new(
28123 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28124 HistorySourceLayout::SingleNdjson,
28125 history_root.clone(),
28126 )
28127 .unwrap(),
28128 ])
28129 .unwrap();
28130 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28131 let held = catalog.lock().unwrap();
28132 let result = timeout(
28133 Duration::from_secs(1),
28134 run_native_session_catalog_operation(
28135 Arc::clone(&catalog),
28136 "native session catalog",
28137 |_| Ok::<(), std::convert::Infallible>(()),
28138 ),
28139 )
28140 .await
28141 .expect("concurrent native catalog request must not wait for the operation deadline")
28142 .unwrap_err();
28143 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28144 drop(held);
28145 std::fs::remove_dir_all(history_root).unwrap();
28146 }
28147
28148 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28149 let unique = std::time::SystemTime::now()
28150 .duration_since(std::time::UNIX_EPOCH)
28151 .unwrap()
28152 .as_nanos();
28153 std::env::temp_dir().join(format!(
28154 "gate4agent-node-{label}-{}-{unique}",
28155 std::process::id(),
28156 ))
28157 }
28158
28159 fn git_snapshot_for_parser() -> GitSnapshot {
28160 GitSnapshot {
28161 is_repository: true,
28162 branch: Some("main".to_owned()),
28163 status: Vec::new(),
28164 recent_commits: Vec::new(),
28165 worktrees: Vec::new(),
28166 managed_worktree: None,
28167 truncated: false,
28168 diagnostic: None,
28169 }
28170 }
28171
28172 #[tokio::test]
28173 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28174 let root = temporary_workspace_root("managed-git-scope");
28175 std::fs::create_dir_all(&root).unwrap();
28176 run_test_git(&root, &["init"]);
28177 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28178 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28179 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28180 run_test_git(&root, &["add", "seed.txt"]);
28181 run_test_git(&root, &["commit", "-m", "seed"]);
28182 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28183
28184 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28185 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28186 let catalog = active_registry().unwrap();
28187 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28188 let shared = NodeShared::new(
28189 handle,
28190 "fixture-token".to_owned(),
28191 NodeId::new("node-1").unwrap(),
28192 vec![workspace],
28193 vec![agent("claude")],
28194 );
28195 let mut lease = ManagedWorktreeLeaseRecord {
28196 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28197 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28198 workspace_id: workspace_id.clone(),
28199 profile_id: WorktreeProfileId::new("default").unwrap(),
28200 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28201 target_root: root.to_string_lossy().into_owned(),
28202 branch: "gate4agent/a".to_owned(),
28203 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28204 expected_head: None,
28205 retention: ManagedWorktreeRetention::Retain,
28206 state: ManagedWorktreeLeaseState::InUse,
28207 session_holders: vec![ManagedWorktreeSessionHolder {
28208 incarnation_id: shared.incarnation_id,
28209 instance_id: AgentInstanceId(7),
28210 generation: SessionGeneration(1),
28211 }],
28212 record_holders: Vec::new(),
28213 cleanup_failure: None,
28214 created_at_unix_ms: 1,
28215 updated_at_unix_ms: 1,
28216 };
28217 *shared
28218 .managed_worktrees
28219 .lock()
28220 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28221 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28222
28223 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28224 let scope = inspection.git.managed_worktree.unwrap();
28225 assert_eq!(scope.lease_id, lease.lease_id);
28226 assert_eq!(scope.branch, "gate4agent/a");
28227 assert_eq!(scope.active_session_count, 1);
28228
28229 lease.branch = "gate4agent/b".to_owned();
28230 *shared
28231 .managed_worktrees
28232 .lock()
28233 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28234 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28235 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28236 assert!(mismatch.git.managed_worktree.is_none());
28237 std::fs::remove_dir_all(root).unwrap();
28238 }
28239
28240 #[tokio::test]
28241 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28242 let root = temporary_workspace_root("workspace-file-write-controller");
28243 std::fs::create_dir_all(root.join("src")).unwrap();
28244 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28245 let workspace_id = WorkspaceId::new("primary").unwrap();
28246 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28247 let catalog = active_registry().unwrap();
28248 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28249 let shared = NodeShared::new(
28250 handle,
28251 "fixture-token".to_owned(),
28252 NodeId::new("node-1").unwrap(),
28253 vec![workspace],
28254 vec![agent("claude")],
28255 );
28256 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28257 let expected_revision = workspace_file_revision(b"old\n");
28258 let request = NodeRequest::WriteWorkspaceFile {
28259 workspace_id: workspace_id.clone(),
28260 path: path.clone(),
28261 expected_revision: expected_revision.clone(),
28262 text: "new\n".to_owned(),
28263 };
28264 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28265 .await
28266 .unwrap_err();
28267 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28268 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28269 .await
28270 .unwrap_err();
28271 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28272 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28273 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28274 let NodeResponse::WorkspaceFileWritten { file } = response else {
28275 panic!("workspace file write returned another response");
28276 };
28277 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28278 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28279 let stale = process_request_inner(
28280 &shared,
28281 77,
28282 ClientRole::Operator,
28283 NodeRequest::WriteWorkspaceFile {
28284 workspace_id,
28285 path,
28286 expected_revision,
28287 text: "lost update\n".to_owned(),
28288 },
28289 )
28290 .await
28291 .unwrap_err();
28292 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28293 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28294 std::fs::remove_dir_all(root).unwrap();
28295 }
28296
28297 #[tokio::test]
28298 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28299 let root = temporary_workspace_root("workspace-entry-create-controller");
28300 std::fs::create_dir_all(root.join("src")).unwrap();
28301 let workspace_id = WorkspaceId::new("primary").unwrap();
28302 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28303 let catalog = active_registry().unwrap();
28304 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28305 let shared = NodeShared::new(
28306 handle,
28307 "fixture-token".to_owned(),
28308 NodeId::new("node-1").unwrap(),
28309 vec![workspace],
28310 vec![agent("claude")],
28311 );
28312 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28313 let request = NodeRequest::CreateWorkspaceFile {
28314 workspace_id: workspace_id.clone(),
28315 path: path.clone(),
28316 };
28317 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28318 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28319 }));
28320 assert!(!request_uses_unnegotiated_capability(
28321 &request,
28322 &baseline_capabilities().unwrap(),
28323 ));
28324 assert!(request_uses_unnegotiated_capability(&request, &[]));
28325 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28326 .await
28327 .unwrap_err();
28328 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28329 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28330 .await
28331 .unwrap_err();
28332 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28333 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28334
28335 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28336 .await
28337 .unwrap();
28338 let NodeResponse::WorkspaceFileCreated { file } = response else {
28339 panic!("workspace file create returned another response");
28340 };
28341 assert_eq!(file.workspace_id, workspace_id);
28342 assert_eq!(file.path, path);
28343 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28344 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28345
28346 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28347 .await
28348 .unwrap_err();
28349 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28350
28351 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28352 let response = process_request_inner(
28353 &shared,
28354 77,
28355 ClientRole::Operator,
28356 NodeRequest::CreateWorkspaceDirectory {
28357 workspace_id: workspace_id.clone(),
28358 path: directory_path.clone(),
28359 },
28360 )
28361 .await
28362 .unwrap();
28363 assert_eq!(
28364 response,
28365 NodeResponse::WorkspaceDirectoryCreated {
28366 workspace_id: workspace_id.clone(),
28367 entry: WorkspaceEntry {
28368 relative_path: directory_path,
28369 kind: WorkspaceEntryKind::Directory,
28370 },
28371 },
28372 );
28373 assert!(root.join("src/nested").is_dir());
28374
28375 let missing_parent = process_request_inner(
28376 &shared,
28377 77,
28378 ClientRole::Operator,
28379 NodeRequest::CreateWorkspaceFile {
28380 workspace_id: workspace_id.clone(),
28381 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28382 },
28383 )
28384 .await
28385 .unwrap_err();
28386 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28387 let non_directory_parent = process_request_inner(
28388 &shared,
28389 77,
28390 ClientRole::Operator,
28391 NodeRequest::CreateWorkspaceFile {
28392 workspace_id,
28393 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28394 },
28395 )
28396 .await
28397 .unwrap_err();
28398 assert_eq!(
28399 non_directory_parent.code,
28400 NodeFailureCode::RepositoryParentNotDirectory,
28401 );
28402 std::fs::remove_dir_all(root).unwrap();
28403 }
28404
28405 #[tokio::test]
28406 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28407 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28408 std::fs::create_dir_all(root.join("src")).unwrap();
28409 #[cfg(windows)]
28410 let root = std::fs::canonicalize(root).unwrap();
28411 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28412 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28413 let barrier = Arc::new(std::sync::Barrier::new(2));
28414 let done = Arc::new(AtomicBool::new(false));
28415 let task_commit_state = Arc::clone(&commit_state);
28416 let task_barrier = Arc::clone(&barrier);
28417 let task_done = Arc::clone(&done);
28418 let task_root = root.clone();
28419 let task = tokio::task::spawn_blocking(move || {
28420 task_barrier.wait();
28421 let result = create_workspace_file_on_disk(
28422 &task_root,
28423 &path,
28424 &task_commit_state,
28425 );
28426 task_done.store(true, Ordering::Release);
28427 result
28428 });
28429
28430 let error = settle_workspace_entry_create(
28431 task,
28432 Arc::clone(&commit_state),
28433 Duration::from_millis(10),
28434 "fixture deadline",
28435 "fixture task failed",
28436 )
28437 .await
28438 .unwrap_err();
28439 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28440 assert_eq!(
28441 commit_state.load(Ordering::Acquire),
28442 WORKSPACE_ENTRY_CREATE_CANCELED,
28443 );
28444
28445 barrier.wait();
28446 timeout(Duration::from_secs(1), async {
28447 while !done.load(Ordering::Acquire) {
28448 tokio::task::yield_now().await;
28449 }
28450 })
28451 .await
28452 .expect("canceled blocking create did not settle");
28453 assert!(!root.join("src/late.rs").exists());
28454 std::fs::remove_dir_all(root).unwrap();
28455 }
28456}
28457
28458#[cfg(test)]
28459mod provisional_spawn_tests {
28460 use super::*;
28461 use std::sync::Arc;
28462
28463 fn shared() -> NodeShared {
28464 let catalog = active_registry().unwrap();
28465 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28466 let workspace = WorkspaceConfig::new(
28467 WorkspaceId::new("primary").unwrap(),
28468 std::env::current_dir().unwrap(),
28469 )
28470 .unwrap()
28471 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28472 NodeShared::new(
28473 handle,
28474 "fixture-token".to_owned(),
28475 NodeId::new("node-provisional-test").unwrap(),
28476 vec![workspace],
28477 vec![AgentId::new("claude").unwrap()],
28478 )
28479 }
28480
28481 #[test]
28484 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28485 let shared = shared();
28486 let ws = WorkspaceId::new("primary").unwrap();
28487 let mut ids = Vec::with_capacity(50);
28488 for i in 1u64..=50 {
28489 let id = AgentInstanceId(i);
28490 ids.push(id);
28491 let address = SessionAddress {
28492 workspace_id: ws.clone(),
28493 session: SessionKey {
28494 instance_id: id,
28495 generation: SessionGeneration(1),
28496 },
28497 };
28498 shared.mark_spawn_provisional(address);
28499 }
28500 assert_eq!(
28501 shared
28502 .provisional_spawns
28503 .lock()
28504 .unwrap_or_else(|p| p.into_inner())
28505 .len(),
28506 50
28507 );
28508 for id in ids.iter().step_by(2) {
28509 shared.sessionize_spawn(*id);
28510 }
28511 assert_eq!(
28512 shared
28513 .provisional_spawns
28514 .lock()
28515 .unwrap_or_else(|p| p.into_inner())
28516 .len(),
28517 25
28518 );
28519 assert!(shared.take_due_provisional_spawns().is_empty());
28520 {
28521 let mut map = shared
28522 .provisional_spawns
28523 .lock()
28524 .unwrap_or_else(|p| p.into_inner());
28525 let past = Instant::now() - Duration::from_secs(91);
28526 for (_, (_, at)) in map.iter_mut() {
28527 *at = past;
28528 }
28529 }
28530 let due = shared.take_due_provisional_spawns();
28531 assert_eq!(due.len(), 25);
28532 let due_ids: std::collections::BTreeSet<_> =
28533 due.iter().map(|a| a.session.instance_id.0).collect();
28534 let expected: std::collections::BTreeSet<_> =
28535 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28536 assert_eq!(due_ids, expected);
28537 assert!(shared
28538 .provisional_spawns
28539 .lock()
28540 .unwrap_or_else(|p| p.into_inner())
28541 .is_empty());
28542 }
28543
28544 #[test]
28547 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28548 let shared = Arc::new(shared());
28549 let ws = WorkspaceId::new("primary").unwrap();
28550 let mut handles = Vec::new();
28551 for i in 1u64..=20 {
28552 let shared = Arc::clone(&shared);
28553 let ws = ws.clone();
28554 handles.push(std::thread::spawn(move || {
28555 let id = AgentInstanceId(i);
28556 let address = SessionAddress {
28557 workspace_id: ws,
28558 session: SessionKey {
28559 instance_id: id,
28560 generation: SessionGeneration(1),
28561 },
28562 };
28563 shared.mark_spawn_provisional(address);
28564 id
28565 }));
28566 }
28567 let mut ids = Vec::new();
28568 for h in handles {
28569 ids.push(h.join().unwrap());
28570 }
28571 ids.sort_by_key(|id| id.0);
28572 assert_eq!(ids.len(), 20);
28573 ids.dedup();
28574 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28575 assert_eq!(
28576 shared
28577 .provisional_spawns
28578 .lock()
28579 .unwrap_or_else(|p| p.into_inner())
28580 .len(),
28581 20
28582 );
28583 shared.sessionize_spawn(AgentInstanceId(7));
28584 let left: Vec<_> = shared
28585 .provisional_spawns
28586 .lock()
28587 .unwrap_or_else(|p| p.into_inner())
28588 .keys()
28589 .map(|id| id.0)
28590 .collect();
28591 assert!(!left.contains(&7));
28592 assert_eq!(left.len(), 19);
28593 }
28594}
28595
28596
28597#[cfg(unix)]
28598extern "C" {
28599 #[link_name = "geteuid"]
28600 fn libc_geteuid() -> u32;
28601}
28602
28603#[cfg(test)]
28604mod call_home_link_tests {
28605 use super::*;
28606
28607 #[test]
28608 fn call_home_backoff_is_capped_and_monotonic() {
28609 let mut last = Duration::ZERO;
28610 for attempt in 1..20 {
28611 let next = call_home_backoff(attempt);
28612 assert!(next >= last);
28613 assert!(next <= Duration::from_secs(8));
28614 last = next;
28615 }
28616 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28617 }
28618
28619 #[tokio::test]
28620 async fn call_home_socket_gets_keepalive() {
28621 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28622 let addr = listener.local_addr().unwrap();
28623 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28624 let client = client.unwrap();
28625 enable_call_home_keepalive(&client).unwrap();
28626 let sock = socket2::SockRef::from(&client);
28627 assert!(sock.keepalive().unwrap());
28628 #[cfg(any(target_os = "linux", target_os = "macos"))]
28629 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28630 }
28631}
28632
28633fn provider_session_exists(provider: &str, id: &str) -> bool {
28635 if provider != "claude" { return false; }
28636 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28637 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28638 let name = format!("{id}.jsonl");
28639 homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28640}
28641
28642fn overlay_with_extra_args(
28644 provider: &AgentId,
28645 mode: SessionMode,
28646 overlay: Option<PreparedNativeLaunchOverlay>,
28647 args: Vec<OsString>,
28648) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28649 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28650 Ok(match (overlay, args) {
28651 (prepared, args) if args.is_empty() => prepared,
28652 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28653 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28654 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28655 provider.clone(),
28656 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28657 Vec::new(),
28658 args,
28659 ).map_err(|_| bad())?)),
28660 })
28661}
28662
28663fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28665 use crate::provider_error::Action;
28666 match act {
28667 Action::Retry { attempt, delay } => tracing::info!(target: "provider_retry", instance_id = instance, provider, via, attempt, max = crate::provider_error::MAX_ATTEMPTS, delay_ms = delay.as_millis() as u64, "provider error: retry scheduled"),
28668 Action::Limit { found } => {
28669 tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28670 if via == "acp" {
28671 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28672 }
28673 }
28674 Action::Escalate { found, attempts } => {
28675 tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28676 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28677 }
28678 }
28679}
28680
28681fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28684 let id = address.session.instance_id.0;
28685 let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28686 match e {
28687 ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28688 ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28689 ProviderEvent::ToolCompleted { is_error, duration_ms, non_execution_kind, .. } => tracing::info!(instance_id = id, is_error, duration_ms = ?duration_ms, not_run = ?non_execution_kind, "acp tool call finished"),
28690 ProviderEvent::InteractionRequested { interaction_kind, tool_name, options, .. } => tracing::info!(instance_id = id, kind = ?interaction_kind, tool = %tool_name, options = options.len(), "acp permission/interaction requested"),
28691 ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28692 ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28693 ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28694 _ => {}
28695 }
28696}