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::Text { text, is_delta } => {
11940 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_text(id, text, *is_delta);
11941 return;
11942 }
11943 ProviderEvent::ToolStarted { .. } => {
11944 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_tool(id);
11945 return;
11946 }
11947 ProviderEvent::SessionEnded { is_error: false, result, .. } => {
11948 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ended(id, result, Instant::now());
11949 if let Some(act) = act {
11950 let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11951 provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11952 }
11953 return;
11954 }
11955 ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => {
11956 let found = crate::provider_error::classify_message(message, vendor_code.as_deref());
11957 let provider = crate::auto_permit::acp_provider(id).unwrap_or("acp");
11958 let window = crate::provider_limits::fresh_5h_used(provider, 10 * 60 * 1000);
11959 crate::provider_error::limit_from_window(found, vendor_code.as_deref(), window)
11960 }
11961 ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
11962 _ => return,
11963 },
11964 _ => return,
11965 };
11966 let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11967 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
11968 if let Some(act) = act {
11969 provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11970 }
11971 }
11972
11973 fn run_due_provider_retries(&self) {
11975 let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
11976 for (id, how, attempt) in due {
11977 let instance_id = AgentInstanceId(id);
11978 let r = match how {
11979 crate::provider_error::Retry::Pty => self
11980 .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
11981 .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
11982 crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
11983 instance_id,
11984 action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
11985 }),
11986 };
11987 match r {
11988 Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
11989 Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
11990 }
11991 }
11992 }
11993
11994 fn mark_spawn_provisional(&self, address: SessionAddress) {
11995 let id = address.session.instance_id;
11996 self.provisional_spawns
11997 .lock()
11998 .unwrap_or_else(|poisoned| poisoned.into_inner())
11999 .insert(id, (address, Instant::now()));
12000 tracing::info!(
12001 node_id = %self.node_id,
12002 instance_id = id.0,
12003 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12004 "draft spawn; waiting for HQ view",
12005 );
12006 }
12007
12008 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
12009 let removed = self
12010 .provisional_spawns
12011 .lock()
12012 .unwrap_or_else(|poisoned| poisoned.into_inner())
12013 .remove(&instance_id);
12014 if removed.is_some() {
12015 tracing::info!(
12016 node_id = %self.node_id,
12017 instance_id = instance_id.0,
12018 "draft spawn claimed by HQ view",
12019 );
12020 }
12021 }
12022
12023 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12024 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12025 let mut map = self
12026 .provisional_spawns
12027 .lock()
12028 .unwrap_or_else(|poisoned| poisoned.into_inner());
12029 let due: Vec<AgentInstanceId> = map
12030 .iter()
12031 .filter(|(_, (_, at))| at.elapsed() >= grace)
12032 .map(|(id, _)| *id)
12033 .collect();
12034 due.into_iter()
12035 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12036 .collect()
12037 }
12038
12039 async fn reap_provisional_spawn(&self, address: SessionAddress) {
12040 let instance_id = address.session.instance_id;
12041 tracing::warn!(
12042 node_id = %self.node_id,
12043 instance_id = instance_id.0,
12044 workspace = %address.workspace_id,
12045 reason = "attach_never_arrived",
12046 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12047 "draft spawn never viewed; stopping PTY",
12048 );
12049 let _ = self.dispatch(ControlCommand::Stop {
12050 instance_id,
12051 force: true,
12052 });
12053 let _ = self.remove_session(&address).await;
12054 }
12055
12056 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12057 if role != ClientRole::Operator {
12058 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12059 }
12060 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12061 let now = Instant::now();
12062 match controller.as_ref().copied() {
12063 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12064 Some(active) if active.expires_at <= now => {
12065 *controller = None;
12066 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12067 }
12068 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12069 }
12070 }
12071
12072 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12073 if !self
12074 .workspaces
12075 .read()
12076 .unwrap_or_else(|poisoned| poisoned.into_inner())
12077 .contains_key(&address.workspace_id)
12078 {
12079 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12080 }
12081 let binding = self
12082 .session_bindings
12083 .lock()
12084 .unwrap_or_else(|poisoned| poisoned.into_inner())
12085 .get(&address.session.instance_id)
12086 .cloned()
12087 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12088 if binding.workspace_id != address.workspace_id {
12089 return Err(failure(
12090 NodeFailureCode::SessionWorkspaceMismatch,
12091 "session belongs to another workspace",
12092 ));
12093 }
12094 if binding.generation != address.session.generation {
12095 return Err(failure(
12096 NodeFailureCode::StaleGeneration,
12097 &format!(
12098 "stale session generation: expected {}, received {}",
12099 binding.generation.0,
12100 address.session.generation.0,
12101 ),
12102 ));
12103 }
12104 let snapshot = self.handle.snapshot();
12105 let Some(current) = snapshot
12106 .sessions
12107 .iter()
12108 .find(|item| item.instance_id == address.session.instance_id)
12109 else {
12110 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12111 };
12112 if current.generation != address.session.generation {
12113 return Err(failure(
12114 NodeFailureCode::StaleGeneration,
12115 &format!(
12116 "stale session generation: expected {}, received {}",
12117 current.generation.0,
12118 address.session.generation.0,
12119 ),
12120 ));
12121 }
12122 Ok(current.agent_id.clone())
12123 }
12124
12125 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12126 self.dispatch_envelope(self.prepare_command(command))
12127 }
12128
12129 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12130 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12131 CommandEnvelope {
12132 id: CommandId(id),
12133 command,
12134 }
12135 }
12136
12137 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12138 let command_id = envelope.id;
12139 self.handle
12140 .dispatch(envelope)
12141 .map_err(|error| match error {
12142 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12143 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12144 })?;
12145 Ok(command_id)
12146 }
12147
12148 fn record_rejected_command(
12157 &self,
12158 command_id: CommandId,
12159 message: String,
12160 unsupported_transport: bool,
12161 ) {
12162 let now = Instant::now();
12163 let mut reasons = self.rejected_command_reasons.lock()
12164 .unwrap_or_else(|poisoned| poisoned.into_inner());
12165 reasons.retain(|_, reason| reason.expires_at > now);
12166 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12167 if let Some(&oldest) = reasons.keys().next() {
12168 reasons.remove(&oldest);
12169 }
12170 }
12171 reasons.insert(
12172 command_id,
12173 RejectedCommandReason {
12174 message,
12175 unsupported_transport,
12176 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12177 },
12178 );
12179 }
12180
12181 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12188 let now = Instant::now();
12189 let mut reasons = self.rejected_command_reasons.lock()
12190 .unwrap_or_else(|poisoned| poisoned.into_inner());
12191 match reasons.remove(&command_id) {
12192 Some(reason) if reason.expires_at > now => Some(reason),
12193 _ => None,
12194 }
12195 }
12196
12197 async fn dispatch_bounded(
12198 &self,
12199 command: ControlCommand,
12200 timeout_duration: Duration,
12201 ) -> Result<CommandId, NodeFailure> {
12202 let deadline = Instant::now() + timeout_duration;
12203 loop {
12204 match self.dispatch(command.clone()) {
12205 Ok(command_id) => return Ok(command_id),
12206 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12207 sleep(Duration::from_millis(2)).await;
12208 }
12209 Err(error) => return Err(error),
12210 }
12211 }
12212 }
12213
12214 fn internal_session_snapshot(
12215 &self,
12216 address: &SessionAddress,
12217 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12218 self.validate_address(address)?;
12219 self.handle
12220 .snapshot()
12221 .sessions
12222 .clone()
12223 .into_iter()
12224 .find(|session| {
12225 session.instance_id == address.session.instance_id
12226 && session.generation == address.session.generation
12227 })
12228 .ok_or_else(|| {
12229 failure(
12230 NodeFailureCode::UnknownSession,
12231 "session snapshot is unavailable",
12232 )
12233 })
12234 }
12235
12236 fn require_acp_session(
12244 &self,
12245 address: &SessionAddress,
12246 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12247 let snapshot = self.internal_session_snapshot(address)?;
12248 if snapshot.transport != TransportKind::Acp {
12249 return Err(failure(
12250 NodeFailureCode::UnsupportedTransport,
12251 "this verb requires a session spawned over the ACP transport",
12252 ));
12253 }
12254 Ok(snapshot)
12255 }
12256
12257 async fn dispatch_history_bounded(
12258 &self,
12259 address: &SessionAddress,
12260 command: ControlCommand,
12261 operation: HistoryOperation,
12262 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12263 self.validate_address(address)?;
12264 let started_after = self.current_sequence();
12265 let command_id = self
12266 .dispatch_bounded(
12267 command,
12268 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12269 )
12270 .await?;
12271 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12272 let mut scan_after = started_after;
12273 let mut accepted_after = None;
12274 loop {
12275 let events = self
12276 .history
12277 .lock()
12278 .unwrap_or_else(|poisoned| poisoned.into_inner())
12279 .events
12280 .iter()
12281 .filter(|envelope| envelope.sequence > scan_after)
12282 .cloned()
12283 .collect::<Vec<_>>();
12284 for envelope in events {
12285 scan_after = scan_after.max(envelope.sequence);
12286 let NodeEvent::Control {
12287 address: event_address,
12288 event,
12289 } = envelope.event
12290 else {
12291 continue;
12292 };
12293 if event_address != *address {
12294 continue;
12295 }
12296 if event.command_id == Some(command_id) {
12297 match &event.event {
12298 ControlEventKind::CommandRejected { .. } => {
12299 return Err(failure(
12300 NodeFailureCode::InvalidRequest,
12301 "history request was rejected",
12302 ));
12303 }
12304 ControlEventKind::HistoryRequested {
12305 operation: requested,
12306 ..
12307 } if requested == &operation => {
12308 accepted_after = Some(envelope.sequence);
12309 continue;
12310 }
12311 _ => {}
12312 }
12313 }
12314 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12315 match (&operation, &event.event) {
12316 (
12317 HistoryOperation::Discover { .. },
12318 ControlEventKind::HistoryDiscovered { .. },
12319 )
12320 | (
12321 HistoryOperation::Load { .. },
12322 ControlEventKind::HistoryLoaded { .. },
12323 ) => return self.internal_session_snapshot(address),
12324 (_, ControlEventKind::HistoryFailed { .. }) => {
12325 return Err(failure(
12326 NodeFailureCode::BackendOperationFailed,
12327 "history operation failed",
12328 ));
12329 }
12330 _ => {}
12331 }
12332 }
12333 }
12334 if Instant::now() >= deadline {
12335 return Err(failure(
12336 NodeFailureCode::BackendBusy,
12337 if accepted_after.is_some() {
12338 "history operation did not settle before the bounded deadline"
12339 } else {
12340 "history operation was not accepted before the bounded deadline"
12341 },
12342 ));
12343 }
12344 sleep(Duration::from_millis(2)).await;
12345 }
12346 }
12347
12348 async fn discover_history(
12349 &self,
12350 address: &SessionAddress,
12351 limit: u16,
12352 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12353 let query = HistoryQuery {
12354 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12355 limit,
12356 };
12357 query
12358 .validate()
12359 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12360 let snapshot = self
12361 .dispatch_history_bounded(
12362 address,
12363 ControlCommand::DiscoverHistory {
12364 instance_id: address.session.instance_id,
12365 query: query.clone(),
12366 },
12367 HistoryOperation::Discover { query },
12368 )
12369 .await?;
12370 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12371 return Err(failure(
12372 NodeFailureCode::BackendOperationFailed,
12373 "history discovery settled into an invalid state",
12374 ));
12375 }
12376 Ok(snapshot.history.candidates)
12377 }
12378
12379 async fn load_history(
12380 &self,
12381 address: &SessionAddress,
12382 candidate_id: String,
12383 ) -> Result<HistorySessionRecord, NodeFailure> {
12384 validate_candidate_id(&candidate_id)
12385 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12386 let snapshot = self
12387 .dispatch_history_bounded(
12388 address,
12389 ControlCommand::LoadHistory {
12390 instance_id: address.session.instance_id,
12391 candidate_id: candidate_id.clone(),
12392 },
12393 HistoryOperation::Load {
12394 candidate_id: candidate_id.clone(),
12395 },
12396 )
12397 .await?;
12398 if snapshot.history.pending.is_some()
12399 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12400 {
12401 return Err(failure(
12402 NodeFailureCode::BackendOperationFailed,
12403 "history load settled into an invalid state",
12404 ));
12405 }
12406 snapshot.history.loaded.ok_or_else(|| {
12407 failure(
12408 NodeFailureCode::BackendOperationFailed,
12409 "history load produced no bounded session",
12410 )
12411 })
12412 }
12413
12414 async fn export_context_pack_for_session_record(
12421 &self,
12422 record_id: &SessionRecordId,
12423 session: &SessionAddress,
12424 allow_clean_detachment: bool,
12425 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12426 let outcome = self
12432 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12433 .await;
12434 if let Err(failure) = &outcome {
12435 tracing::warn!(
12436 record = %record_id,
12437 code = ?failure.code,
12438 reason = %failure.message,
12439 "session-record context-pack export refused",
12440 );
12441 }
12442 outcome
12443 }
12444
12445 async fn export_context_pack_for_session_record_inner(
12446 &self,
12447 record_id: &SessionRecordId,
12448 session: &SessionAddress,
12449 allow_clean_detachment: bool,
12450 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12451 let step = |name: &'static str| move |failure: NodeFailure| {
12456 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12457 failure
12458 };
12459 let record = self.record(record_id).map_err(step("record-lookup"))?;
12460 let identity = match record.provider_session.clone() {
12476 Some(identity) => {
12477 if identity.key != ProviderSessionKey::SessionId {
12478 return Err(step("provider-identity-key")(failure(
12479 NodeFailureCode::BackendOperationFailed,
12480 "managed session provider identity cannot bind history exactly",
12481 )));
12482 }
12483 Some(identity)
12484 }
12485 None if record.state == ManagedSessionState::IdentityPending => {
12486 return Err(step("provider-session-identity")(failure(
12487 NodeFailureCode::BackendOperationFailed,
12488 "managed session has not yet observed its provider session identity",
12489 )));
12490 }
12491 None => None,
12492 };
12493 self.revalidate_session_record_context_export(
12494 &record,
12495 identity.as_ref(),
12496 session,
12497 allow_clean_detachment,
12498 ).map_err(step("revalidate-before-discovery"))?;
12499 let candidates = self
12500 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12501 .await
12502 .map_err(step("discover-history"))?;
12503 let candidate_id = select_history_candidate(
12508 &candidates,
12509 identity.as_ref().map(|identity| identity.id.as_str()),
12510 ).map_err(step("unique-candidate"))?;
12511 let loaded = self.load_history(session, candidate_id.clone()).await
12512 .map_err(step("load-history"))?;
12513 if let Some(identity) = identity.as_ref() {
12514 if loaded.session_id != identity.id {
12515 return Err(failure(
12516 NodeFailureCode::SessionRecordConflict,
12517 "loaded history identity does not match the managed session record",
12518 ));
12519 }
12520 }
12521 self.revalidate_session_record_context_export(
12522 &record,
12523 identity.as_ref(),
12524 session,
12525 allow_clean_detachment,
12526 )?;
12527 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12528 .map_err(step("revalidate-loaded-history"))?;
12529 let pack = self.materialize_context_pack(session).await
12530 .map_err(step("materialize-context-pack"))?;
12531 self.commit_context_pack_for_session_record(
12532 &record,
12533 identity.as_ref(),
12534 session,
12535 &candidate_id,
12536 &loaded,
12537 pack,
12538 allow_clean_detachment,
12539 )
12540 }
12541
12542 async fn export_context_pack(
12543 &self,
12544 address: &SessionAddress,
12545 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12546 let pack = self.materialize_context_pack(address).await?;
12547 self.commit_context_pack(pack)
12548 }
12549
12550 async fn materialize_context_pack(
12551 &self,
12552 address: &SessionAddress,
12553 ) -> Result<NodeContextPack, NodeFailure> {
12554 let snapshot = self.internal_session_snapshot(address)?;
12555 if !context_pack_source_status_is_usable(&snapshot.status) {
12556 return Err(failure(
12557 NodeFailureCode::InvalidRequest,
12558 "context export requires a running or successfully exited source session",
12559 ));
12560 }
12561 if snapshot.history.pending.is_some()
12562 || snapshot.history.loaded_candidate_id.is_none()
12563 {
12564 return Err(failure(
12565 NodeFailureCode::InvalidRequest,
12566 "context export requires settled loaded history",
12567 ));
12568 }
12569 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12570 failure(
12571 NodeFailureCode::InvalidRequest,
12572 "context export requires settled loaded history",
12573 )
12574 })?;
12575 let source_provider = snapshot.agent_id.clone();
12576 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12577 let repository = self
12578 .context_pack_repository(&address.workspace_id)
12579 .await?;
12580 let current = self.internal_session_snapshot(address)?;
12581 if !context_pack_source_status_is_usable(¤t.status)
12582 || current.agent_id != source_provider
12583 || current.history.pending.is_some()
12584 || current.history.loaded_candidate_id != loaded_candidate_id
12585 || current.history.loaded.as_ref() != Some(&loaded)
12586 {
12587 return Err(failure(
12588 NodeFailureCode::ContextPackMaterializationFailed,
12589 "context source changed during bounded repository capture",
12590 ));
12591 }
12592 NodeContextPack::export_with_repository(
12593 ContextPackLineageReceipt {
12594 source_node_id: self.node_id.clone(),
12595 source_session: address.clone(),
12596 source_provider,
12597 },
12598 &loaded,
12599 Some(repository),
12600 )
12601 .map_err(|_| {
12602 failure(
12603 NodeFailureCode::ContextPackMaterializationFailed,
12604 "bounded context pack export failed",
12605 )
12606 })
12607 }
12608
12609 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12615 let mut store = self
12616 .context_pack_store
12617 .lock()
12618 .unwrap_or_else(|poisoned| poisoned.into_inner());
12619 let Some(store) = store.as_mut() else {
12620 return Ok(());
12621 };
12622 store.commit(pack).map_err(|_| {
12623 failure(
12624 NodeFailureCode::ContextPackMaterializationFailed,
12625 "durable context pack commit failed",
12626 )
12627 })
12628 }
12629
12630 fn commit_context_pack(
12631 &self,
12632 pack: NodeContextPack,
12633 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12634 self.commit_context_pack_durably(&pack)?;
12635 self.context_catalog
12636 .write()
12637 .unwrap_or_else(|poisoned| poisoned.into_inner())
12638 .insert(pack)
12639 .map_err(|_| {
12640 failure(
12641 NodeFailureCode::ContextPackMaterializationFailed,
12642 "context pack catalog is full",
12643 )
12644 })
12645 }
12646
12647 fn commit_context_pack_for_session_record(
12648 &self,
12649 expected: &ManagedSessionRecord,
12650 identity: Option<&ProviderSessionIdentity>,
12651 session: &SessionAddress,
12652 candidate_id: &str,
12653 loaded: &HistorySessionRecord,
12654 pack: NodeContextPack,
12655 allow_clean_detachment: bool,
12656 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12657 let current_root = self.workspace_root(&expected.workspace_id)?;
12658 let _transaction = self
12659 .state_transaction
12660 .lock()
12661 .unwrap_or_else(|poisoned| poisoned.into_inner());
12662 let records = self
12663 .session_records
12664 .lock()
12665 .unwrap_or_else(|poisoned| poisoned.into_inner());
12666 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12667 failure(
12668 NodeFailureCode::SessionRecordConflict,
12669 "managed session record disappeared during context export",
12670 )
12671 })?;
12672 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12673 return Err(failure(
12674 NodeFailureCode::SessionRecordConflict,
12675 "managed session record changed during context export",
12676 ));
12677 }
12678 let bindings = self
12679 .session_bindings
12680 .lock()
12681 .unwrap_or_else(|poisoned| poisoned.into_inner());
12682 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12683 failure(
12684 NodeFailureCode::SessionRecordConflict,
12685 "managed session runtime binding disappeared during context export",
12686 )
12687 })?;
12688 let runtime = self
12689 .handle
12690 .snapshot()
12691 .sessions
12692 .iter()
12693 .find(|snapshot| {
12694 snapshot.instance_id == session.session.instance_id
12695 && snapshot.generation == session.session.generation
12696 })
12697 .cloned()
12698 .ok_or_else(|| {
12699 failure(
12700 NodeFailureCode::SessionRecordConflict,
12701 "managed session runtime disappeared during context export",
12702 )
12703 })?;
12704 let receipt = pack.receipt();
12705 if !session_record_context_export_binding_is_exact(
12706 expected,
12707 current,
12708 identity,
12709 session,
12710 binding,
12711 &runtime.agent_id,
12712 ¤t_root,
12713 allow_clean_detachment,
12714 ) || !session_record_context_export_source_is_usable(
12715 current,
12716 &runtime.status,
12717 allow_clean_detachment,
12718 ) || runtime.history.pending.is_some()
12719 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12720 || runtime.history.loaded.as_ref() != Some(loaded)
12721 || receipt.lineage.source_node_id != self.node_id
12722 || receipt.lineage.source_session != *session
12723 || receipt.lineage.source_provider != current.provider
12724 || receipt.source_message_count != loaded.message_count
12725 {
12726 return Err(failure(
12727 NodeFailureCode::SessionRecordConflict,
12728 "managed session source changed before context catalog commit",
12729 ));
12730 }
12731 self.commit_context_pack_durably(&pack)?;
12732 self.context_catalog
12733 .write()
12734 .unwrap_or_else(|poisoned| poisoned.into_inner())
12735 .insert(pack)
12736 .map_err(|_| {
12737 failure(
12738 NodeFailureCode::ContextPackMaterializationFailed,
12739 "context pack catalog is full",
12740 )
12741 })
12742 }
12743
12744 async fn context_pack_repository(
12745 &self,
12746 workspace_id: &WorkspaceId,
12747 ) -> Result<ContextPackRepository, NodeFailure> {
12748 let first = self
12749 .context_pack_repository_observation(workspace_id)
12750 .await?;
12751 let second = self
12752 .context_pack_repository_observation(workspace_id)
12753 .await?;
12754 stable_context_pack_repository(first, second)
12755 }
12756
12757 async fn context_pack_repository_observation(
12758 &self,
12759 workspace_id: &WorkspaceId,
12760 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12761 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12762 let head = if git.is_repository {
12763 let root = self.workspace_root(workspace_id)?;
12764 match resolve_base_commit_with_timeout(
12765 &root,
12766 "HEAD",
12767 GIT_COMMAND_TIMEOUT_MS,
12768 )
12769 .await
12770 {
12771 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12772 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12773 ContextPackRepositoryHead::Unborn
12774 }
12775 Err(_) => {
12776 return Err(failure(
12777 NodeFailureCode::ContextPackMaterializationFailed,
12778 "context pack HEAD observation failed",
12779 ));
12780 }
12781 }
12782 } else {
12783 ContextPackRepositoryHead::NotRepository
12784 };
12785 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12786 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12787 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12788 failure(
12789 NodeFailureCode::InvalidRepositoryPath,
12790 "context pack selected file path is invalid",
12791 )
12792 })?;
12793 match self.read_workspace_file(workspace_id.clone(), path).await {
12794 Ok(WorkspaceFileRead {
12795 content: WorkspaceFileContent::Utf8 { text, byte_len },
12796 ..
12797 }) => files.push(ContextPackRepositoryFileSource::utf8(
12798 selected_path,
12799 text,
12800 byte_len,
12801 )),
12802 Ok(WorkspaceFileRead {
12803 content: WorkspaceFileContent::NonUtf8 { byte_len },
12804 ..
12805 }) => files.push(ContextPackRepositoryFileSource::skipped(
12806 selected_path,
12807 ContextPackSelectedFileSkipReason::NonUtf8,
12808 Some(byte_len),
12809 )),
12810 Ok(WorkspaceFileRead {
12811 content: WorkspaceFileContent::TooLarge { .. },
12812 ..
12813 }) => files.push(ContextPackRepositoryFileSource::skipped(
12814 selected_path,
12815 ContextPackSelectedFileSkipReason::TooLarge,
12816 None,
12817 )),
12818 Err(error) => {
12819 let reason = if matches!(
12820 error.code,
12821 NodeFailureCode::InvalidRepositoryPath
12822 | NodeFailureCode::RepositoryFileNotRegular
12823 | NodeFailureCode::RepositoryPathUnsafe
12824 ) {
12825 ContextPackSelectedFileSkipReason::Unsafe
12826 } else {
12827 ContextPackSelectedFileSkipReason::Unavailable
12828 };
12829 files.push(ContextPackRepositoryFileSource::skipped(
12830 selected_path,
12831 reason,
12832 None,
12833 ));
12834 }
12835 }
12836 }
12837 Ok(ContextPackRepositoryObservation { head, git, files })
12838 }
12839
12840 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12841 let referenced_by_binding = self
12842 .session_bindings
12843 .lock()
12844 .unwrap_or_else(|poisoned| poisoned.into_inner())
12845 .values()
12846 .any(|binding| {
12847 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12848 });
12849 let referenced_by_record = self
12850 .session_records
12851 .lock()
12852 .unwrap_or_else(|poisoned| poisoned.into_inner())
12853 .records
12854 .values()
12855 .any(|record| record.context_id.as_ref() == Some(context_id));
12856 let referenced_by_materialization = self
12857 .materializations
12858 .lock()
12859 .unwrap_or_else(|poisoned| poisoned.into_inner())
12860 .values()
12861 .any(|ownership| {
12862 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12863 });
12864 if referenced_by_binding
12865 || referenced_by_record
12866 || referenced_by_materialization
12867 {
12868 return Err(failure(
12869 NodeFailureCode::ContextPackBusy,
12870 "context pack is still referenced",
12871 ));
12872 }
12873 let mut catalog = self.context_catalog
12874 .write()
12875 .unwrap_or_else(|poisoned| poisoned.into_inner());
12876 if catalog.get(context_id).is_none() {
12877 return Err(failure(
12878 NodeFailureCode::UnknownContextPack,
12879 "context pack is unavailable",
12880 ));
12881 }
12882 self.spawn_idempotency
12883 .lock()
12884 .unwrap_or_else(|poisoned| poisoned.into_inner())
12885 .entries
12886 .retain(|_, entry| !entry.value.references_context(context_id));
12887 let removed = catalog.remove(context_id);
12888 debug_assert!(removed.is_some(), "validated context pack remains present");
12889 Ok(())
12890 }
12891
12892 async fn dispatch_input_bounded(
12893 &self,
12894 address: &SessionAddress,
12895 action: InputAction,
12896 ) -> Result<(), NodeFailure> {
12897 let started_after = self.current_sequence();
12898 self.pty_input_marks
12899 .lock()
12900 .unwrap_or_else(|poisoned| poisoned.into_inner())
12901 .insert(address.session.instance_id, (Instant::now(), false));
12902 self.pty_input_wake.notify_one();
12903 let command_id = self
12904 .dispatch_bounded(
12905 ControlCommand::SendInput {
12906 instance_id: address.session.instance_id,
12907 action,
12908 },
12909 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12910 )
12911 .await?;
12912 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12916 let mut scan_after = started_after;
12917 let mut accepted_after = None;
12918 loop {
12919 let events = self
12920 .history
12921 .lock()
12922 .unwrap_or_else(|poisoned| poisoned.into_inner())
12923 .events
12924 .iter()
12925 .filter(|envelope| envelope.sequence > scan_after)
12926 .cloned()
12927 .collect::<Vec<_>>();
12928 for envelope in events {
12929 scan_after = scan_after.max(envelope.sequence);
12930 let NodeEvent::Control {
12931 address: event_address,
12932 event,
12933 } = envelope.event
12934 else {
12935 continue;
12936 };
12937 if event_address != *address {
12938 continue;
12939 }
12940 if event.command_id == Some(command_id) {
12941 match &event.event {
12942 ControlEventKind::CommandRejected { message } => {
12943 return Err(failure(NodeFailureCode::InvalidRequest, message));
12944 }
12945 ControlEventKind::InputRequested { .. } => {
12946 accepted_after = Some(envelope.sequence);
12947 continue;
12948 }
12949 _ => {}
12950 }
12951 }
12952 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12953 match &event.event {
12954 ControlEventKind::InputCompleted { .. } => return Ok(()),
12955 ControlEventKind::InputFailed { message, .. } => {
12956 return Err(failure(
12957 NodeFailureCode::BackendOperationFailed,
12958 message,
12959 ));
12960 }
12961 _ => {}
12962 }
12963 }
12964 }
12965 if Instant::now() >= deadline {
12966 return Err(failure(
12967 NodeFailureCode::BackendBusy,
12968 if accepted_after.is_some() {
12969 "input was accepted but did not settle before the bounded deadline"
12970 } else {
12971 "input was not accepted before the bounded deadline"
12972 },
12973 ));
12974 }
12975 sleep(Duration::from_millis(2)).await;
12976 }
12977 }
12978
12979 async fn dispatch_resize_bounded(
12980 &self,
12981 address: &SessionAddress,
12982 size: gate4agent_types::TerminalSize,
12983 ) -> Result<(), NodeFailure> {
12984 let started_after = self.current_sequence();
12985 let command_id = self
12986 .dispatch_bounded(
12987 ControlCommand::Resize {
12988 instance_id: address.session.instance_id,
12989 size,
12990 },
12991 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12992 )
12993 .await?;
12994 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12995 let mut scan_after = started_after;
12996 let mut accepted_after = None;
12997 loop {
12998 let events = self
12999 .history
13000 .lock()
13001 .unwrap_or_else(|poisoned| poisoned.into_inner())
13002 .events
13003 .iter()
13004 .filter(|envelope| envelope.sequence > scan_after)
13005 .cloned()
13006 .collect::<Vec<_>>();
13007 for envelope in events {
13008 scan_after = scan_after.max(envelope.sequence);
13009 let NodeEvent::Control {
13010 address: event_address,
13011 event,
13012 } = envelope.event
13013 else {
13014 continue;
13015 };
13016 if event_address != *address {
13017 continue;
13018 }
13019 if event.command_id == Some(command_id) {
13020 match &event.event {
13021 ControlEventKind::CommandRejected { message } => {
13022 return Err(failure(NodeFailureCode::InvalidRequest, message));
13023 }
13024 ControlEventKind::ResizeRequested {
13025 size: requested_size,
13026 ..
13027 } if *requested_size == size => {
13028 accepted_after = Some(envelope.sequence);
13029 continue;
13030 }
13031 _ => {}
13032 }
13033 }
13034 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13035 match &event.event {
13036 ControlEventKind::Resized { size: settled_size }
13037 if *settled_size == size =>
13038 {
13039 return Ok(());
13040 }
13041 ControlEventKind::ResizeFailed { message } => {
13042 return Err(failure(
13043 NodeFailureCode::BackendOperationFailed,
13044 message,
13045 ));
13046 }
13047 _ => {}
13048 }
13049 }
13050 }
13051 if Instant::now() >= deadline {
13052 return Err(failure(
13053 NodeFailureCode::BackendBusy,
13054 if accepted_after.is_some() {
13055 "resize was accepted but did not settle before the bounded deadline"
13056 } else {
13057 "resize was not accepted before the bounded deadline"
13058 },
13059 ));
13060 }
13061 sleep(Duration::from_millis(2)).await;
13062 }
13063 }
13064
13065 async fn spawn_session(
13066 &self,
13067 workspace_id: WorkspaceId,
13068 provider: AgentId,
13069 mode: SessionMode,
13070 terminal_size: gate4agent_types::TerminalSize,
13071 initial_prompt: Option<String>,
13072 ) -> Result<SessionAddress, NodeFailure> {
13073 if self.managed_worktrees.lock()
13074 .unwrap_or_else(|poisoned| poisoned.into_inner())
13075 .lease_for_workspace(&workspace_id)
13076 .is_some()
13077 {
13078 return Err(failure(
13079 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13080 "managed worktree targets may only be spawned through their lease request",
13081 ));
13082 }
13083 self
13084 .spawn_session_with_deadline(
13085 workspace_id,
13086 provider.clone(),
13087 mode,
13088 terminal_size,
13089 initial_prompt,
13090 None,
13091 None,
13092 None,
13093 None,
13094 None,
13095 SpawnRecordPolicy::ProviderIdentityOnly,
13096 None,
13097 &[],
13098 None,
13099 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13106 None,
13107 None,
13108 )
13109 .await
13110 .map(|(session, _runtime_policy)| session)
13111 }
13112
13113 async fn spawn_session_with_deadline(
13114 &self,
13115 workspace_id: WorkspaceId,
13116 provider: AgentId,
13117 mode: SessionMode,
13118 terminal_size: gate4agent_types::TerminalSize,
13119 initial_prompt: Option<String>,
13120 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13121 bundle: Option<ResolvedBundleReceipt>,
13122 context: Option<ResolvedContextPackReceipt>,
13123 managed_authority: Option<ManagedWorktreeLeaseId>,
13124 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13125 record_policy: SpawnRecordPolicy,
13126 deadline: Option<Instant>,
13127 required_capabilities: &[ProviderRuntimeCapability],
13128 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13129 approval_level: ApprovalLevel,
13130 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13131 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13137 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13138 let reserved_lease = self.managed_worktrees.lock()
13139 .unwrap_or_else(|poisoned| poisoned.into_inner())
13140 .lease_for_workspace(&workspace_id);
13141 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13142 (Some(expected), Some(provided)) if expected == provided => {}
13143 (None, None) => {}
13144 _ => {
13145 return Err(failure(
13146 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13147 "spawn lacks exact managed worktree lease authority",
13148 ));
13149 }
13150 }
13151 self.ensure_workspace_environment_profile_compatible(
13155 &workspace_id,
13156 environment_profile.as_ref(),
13157 )?;
13158 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13159 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13160 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13161 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13162 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13163 };
13164 let transport = match mode {
13170 SessionMode::Pty => TransportKind::Pty,
13171 SessionMode::Inline => TransportKind::Pipe,
13172 SessionMode::Acp => TransportKind::Acp,
13173 };
13174 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13175 let acp_transport = self.provider_supports_acp_transport(&provider);
13176 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13177 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13178 NodeFailureCode::UnsupportedSpawnCapability,
13179 "pre-admitted provider runtime no longer satisfies spawn requirements",
13180 ))?;
13181 runtime_policy
13182 } else if let Some(deadline) = deadline {
13183 let remaining = spawn_deadline_remaining(deadline)?;
13184 timeout(
13185 remaining,
13186 self.admit_provider_runtime(&provider, runtime_requirement),
13187 )
13188 .await
13189 .map_err(|_| {
13190 failure(
13191 NodeFailureCode::SpawnDeadlineExceeded,
13192 "provider admission exceeded the spawn deadline",
13193 )
13194 })??
13195 } else {
13196 self.admit_provider_runtime(&provider, runtime_requirement).await?
13197 };
13198 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13199 let runtime_policy =
13211 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13212 if required_capabilities
13213 .iter()
13214 .any(|capability| !runtime_policy.admits(*capability))
13215 {
13216 return Err(failure(
13217 NodeFailureCode::UnsupportedSpawnCapability,
13218 "provider runtime does not admit a required spawn capability",
13219 ));
13220 }
13221 if let Some(deadline) = deadline {
13222 spawn_deadline_remaining(deadline)?;
13223 }
13224 self.ensure_binding_capacity()?;
13225 let mut working_directory = self.workspace_root(&workspace_id)?;
13226 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13227 let session = SessionKey {
13228 instance_id,
13229 generation: SessionGeneration(1),
13230 };
13231 let address = SessionAddress {
13232 workspace_id: workspace_id.clone(),
13233 session,
13234 };
13235 tracing::info!(
13243 node_id = %self.node_id,
13244 workspace_id = %workspace_id,
13245 provider = %provider,
13246 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13247 session = ?address.session,
13248 "session spawn request accepted",
13249 );
13250 let mut prepared_spawn = SpawnPreparer::prepare(
13251 self,
13252 SpawnPrepareRequest {
13253 instance_id,
13254 address: &address,
13255 provider: &provider,
13256 mode,
13257 environment_profile: environment_profile.as_ref(),
13258 bundle: bundle.as_ref(),
13259 context: context.as_ref(),
13260 managed_authority: managed_authority.as_ref(),
13261 harness_mcp,
13262 approval_level,
13263 network_allowlist: network_allowlist.as_ref(),
13264 browser_profile_id,
13265 },
13266 )?;
13267 let materialization_id = prepared_spawn.materialization_id.take();
13268 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13269 let record_id = self.bind_spawn_session_with_materialization(
13270 &address,
13271 provider.clone(),
13272 mode,
13273 runtime_policy,
13274 record_policy,
13275 environment_profile,
13276 bundle,
13277 context,
13278 materialization_id,
13279 )?;
13280 prepared_spawn.retain_session_guards();
13281 let session_provider = provider.clone();
13285 let agent_id = provider;
13286 let register_command_id = match self.dispatch_bounded(
13287 ControlCommand::Register {
13288 instance_id,
13289 agent_id,
13290 transport,
13291 },
13292 dispatch_timeout,
13293 )
13294 .await {
13295 Ok(command_id) => command_id,
13296 Err(error) => {
13297 if let Some(binding) = self.remove_binding(&address) {
13298 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13299 }
13300 if let Some(record_id) = record_id.as_ref() {
13301 self.discard_record(record_id)?;
13302 }
13303 return Err(spawn_dispatch_error(error, deadline));
13304 }
13305 };
13306 let start_timeout = match spawn_dispatch_timeout(deadline) {
13307 Ok(timeout) => timeout,
13308 Err(error) => {
13309 let recovery = self
13310 .rollback_spawn(
13311 &address,
13312 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13313 )
13314 .await;
13315 return match recovery {
13316 Ok(()) => {
13317 if let Some(record_id) = record_id.as_ref() {
13318 self.discard_record(record_id)?;
13319 }
13320 Err(error)
13321 }
13322 Err(recovery_error) => {
13323 if let Some(record_id) = record_id.as_ref() {
13324 self.mark_record_error(
13325 record_id,
13326 "spawn deadline elapsed and registration recovery failed",
13327 )?;
13328 }
13329 Err(recovery_error)
13330 }
13331 };
13332 }
13333 };
13334 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13335 if let Err(recovery_error) = self
13336 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13337 .await
13338 {
13339 return Err(recovery_error);
13340 }
13341 if let Some(record_id) = record_id.as_ref() {
13342 self.discard_record(record_id)?;
13343 }
13344 return Err(start_error);
13345 }
13346 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13348 working_directory = dir.to_string_lossy().into_owned();
13349 }
13350 let start_result = self
13351 .dispatch_bounded(
13352 ControlCommand::Start {
13353 instance_id,
13354 runtime_policy,
13355 request: StartRequest {
13356 working_directory,
13357 terminal_size,
13358 initial_prompt,
13359 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13360 approval_level,
13361 },
13362 },
13363 start_timeout,
13364 )
13365 .await;
13366 let start_command_id = match start_result {
13367 Ok(command_id) => command_id,
13368 Err(start_error) => {
13369 let start_error = spawn_dispatch_error(start_error, deadline);
13370 let recovery = self
13371 .rollback_spawn(
13372 &address,
13373 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13374 )
13375 .await;
13376 return match recovery {
13377 Ok(()) => {
13378 if let Some(record_id) = record_id.as_ref() {
13379 self.discard_record(record_id)?;
13380 }
13381 Err(start_error)
13382 }
13383 Err(recovery_error) => {
13384 let diagnostic = format!(
13385 "start failed and registration recovery failed: {}",
13386 recovery_error.message,
13387 );
13388 if let Some(record_id) = record_id.as_ref() {
13389 self.mark_record_error(record_id, &diagnostic)?;
13390 }
13391 Err(failure(recovery_error.code, &diagnostic))
13392 }
13393 };
13394 }
13395 };
13396
13397 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13398 loop {
13399 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13408 .or_else(|| self.take_rejected_command_reason(start_command_id))
13409 {
13410 self.rollback_spawn(
13411 &address,
13412 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13413 ).await.map_err(|error| {
13414 failure(
13415 error.code,
13416 &format!(
13417 "spawn was rejected ({}) and registration recovery failed: {}",
13418 reason.message, error.message
13419 ),
13420 )
13421 })?;
13422 if let Some(record_id) = record_id.as_ref() {
13423 self.discard_record(record_id)?;
13424 }
13425 let code = if reason.unsupported_transport {
13426 NodeFailureCode::UnsupportedTransport
13427 } else {
13428 NodeFailureCode::BackendOperationFailed
13429 };
13430 return Err(failure(code, &reason.message));
13431 }
13432 if let Some(current) = self
13433 .handle
13434 .snapshot()
13435 .sessions
13436 .iter()
13437 .find(|current| current.instance_id == instance_id)
13438 {
13439 if current.generation != session.generation {
13440 let divergence = failure(
13441 NodeFailureCode::StaleGeneration,
13442 "spawn generation diverged",
13443 );
13444 return match self
13445 .rollback_spawn(
13446 &address,
13447 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13448 )
13449 .await
13450 {
13451 Ok(()) => {
13452 if let Some(record_id) = record_id.as_ref() {
13453 self.discard_record(record_id)?;
13454 }
13455 Err(divergence)
13456 }
13457 Err(recovery_error) => {
13458 if let Some(record_id) = record_id.as_ref() {
13459 self.mark_record_error(
13460 record_id,
13461 "spawn generation diverged and recovery failed",
13462 )?;
13463 }
13464 Err(recovery_error)
13465 }
13466 };
13467 }
13468 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13469 prepared_spawn.retain_launch_guards();
13470 return Ok((address, runtime_policy));
13471 }
13472 }
13473 if Instant::now() >= commit_deadline {
13474 self.rollback_spawn(
13475 &address,
13476 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13477 ).await.map_err(|error| {
13478 failure(
13479 error.code,
13480 &format!(
13481 "start did not commit and registration recovery failed: {}",
13482 error.message
13483 ),
13484 )
13485 })?;
13486 if let Some(record_id) = record_id.as_ref() {
13487 self.discard_record(record_id)?;
13488 }
13489 return Err(failure(
13490 if deadline.is_some() {
13491 NodeFailureCode::SpawnDeadlineExceeded
13492 } else {
13493 NodeFailureCode::BackendBusy
13494 },
13495 "start did not commit before the bounded deadline; registration was removed",
13496 ));
13497 }
13498 sleep(Duration::from_millis(2)).await;
13499 }
13500 }
13501
13502 fn effective_spawn_runtime_policy(
13503 &self,
13504 runtime_policy: ProviderRuntimePolicy,
13505 ) -> ProviderRuntimePolicy {
13506 #[cfg(feature = "fixture")]
13507 let runtime_policy = if self.fixture_semantic_hook_policy {
13508 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13509 .expect("monitoring Hook fixture policy is internally valid")
13510 } else {
13511 runtime_policy
13512 };
13513 runtime_policy
13514 }
13515
13516 fn verify_harness_mcp_before_start(
13517 &self,
13518 prepared: Option<&PreparedHarnessMcpSpawn>,
13519 ) -> Result<(), NodeFailure> {
13520 prepared
13521 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13522 .transpose()
13523 .map(|_| ())
13524 .map_err(harness_mcp_failure)
13525 }
13526
13527 async fn rollback_spawn(
13528 &self,
13529 address: &SessionAddress,
13530 commit_timeout: Duration,
13531 ) -> Result<(), NodeFailure> {
13532 self.dispatch_bounded(
13533 ControlCommand::Remove {
13534 instance_id: address.session.instance_id,
13535 },
13536 commit_timeout,
13537 )
13538 .await?;
13539 self.wait_until_removed(address, commit_timeout).await?;
13540 if let Some(binding) = self.remove_binding(address) {
13541 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13542 }
13543 Ok(())
13544 }
13545
13546 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13547 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13548 self.dispatch_bounded(
13549 ControlCommand::Remove {
13550 instance_id: address.session.instance_id,
13551 },
13552 commit_timeout,
13553 )
13554 .await?;
13555 self.wait_until_removed(address, commit_timeout).await?;
13556 let removed = self.remove_binding(address);
13557 if let Some(binding) = removed.as_ref() {
13558 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13559 }
13560 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13561 let snapshot = self.managed_worktrees.lock()
13562 .unwrap_or_else(|poisoned| poisoned.into_inner())
13563 .release_session(
13564 &lease_id,
13565 self.incarnation_id,
13566 address.session.instance_id,
13567 address.session.generation,
13568 unix_time_ms(),
13569 );
13570 if self.persist_state().is_err() {
13571 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13572 }
13573 if let Some(snapshot) = snapshot {
13574 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13575 }
13576 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13577 }
13578 Ok(())
13579 }
13580
13581 async fn wait_until_removed(
13627 &self,
13628 address: &SessionAddress,
13629 commit_timeout: Duration,
13630 ) -> Result<(), NodeFailure> {
13631 let deadline = Instant::now() + commit_timeout;
13632 let mut next_dispatch = Instant::now();
13633 loop {
13634 let snapshot = self.handle.snapshot();
13635 let status = snapshot
13636 .sessions
13637 .iter()
13638 .find(|session| session.instance_id == address.session.instance_id)
13639 .map(|session| session.status.clone());
13640 let Some(status) = status else {
13641 return Ok(());
13642 };
13643 let now = Instant::now();
13644 if now >= deadline {
13645 return Err(failure(
13646 NodeFailureCode::BackendBusy,
13647 "remove did not commit before the bounded deadline; session binding was retained",
13648 ));
13649 }
13650 if status.allows_remove() && now >= next_dispatch {
13651 let _ = self.dispatch(ControlCommand::Remove {
13652 instance_id: address.session.instance_id,
13653 });
13654 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13655 }
13656 sleep(Duration::from_millis(2)).await;
13657 }
13658 }
13659
13660 fn resync(&self, after_sequence: u64) -> NodeResponse {
13661 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13662 let event_sequence = history.last_sequence;
13663 let oldest_available_sequence = history.replay_floor_sequence;
13664 let events = history
13665 .events
13666 .iter()
13667 .filter(|event| event.sequence > after_sequence)
13668 .cloned()
13669 .collect();
13670 drop(history);
13671 NodeResponse::Resync {
13672 event_sequence,
13673 oldest_available_sequence,
13674 snapshot: self.snapshot(),
13675 events,
13676 }
13677 }
13678}
13679
13680fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13685 match kind {
13686 AgentStreamChunkKindV1::Text { .. } => "text",
13687 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13688 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13689 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13690 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13691 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13692 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13693 }
13694}
13695
13696async fn accept_connections(
13710 endpoint: &str,
13711 call_home: Option<std::net::SocketAddr>,
13712 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13713 shared: Arc<NodeShared>,
13714) -> Result<(), NodeServerError> {
13715 let mut connections = JoinSet::new();
13716 let result = match call_home {
13717 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13718 Some(relay) => {
13719 let listener =
13720 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13721 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13722 tokio::pin!(listener);
13723 tokio::pin!(dialler);
13724 tokio::select! {
13725 result = &mut listener => result,
13726 result = &mut dialler => result,
13727 }
13728 }
13729 };
13730 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13731 while !connections.is_empty() {
13732 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13733 if remaining.is_zero()
13734 || timeout(remaining, connections.join_next()).await.is_err()
13735 {
13736 break;
13737 }
13738 }
13739 connections.abort_all();
13740 while connections.join_next().await.is_some() {}
13741 result
13742}
13743
13744const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13748
13749async fn call_home_loop(
13763 relay: std::net::SocketAddr,
13764 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13765 shared: Arc<NodeShared>,
13766) -> Result<(), NodeServerError> {
13767 let mut failures = 0_usize;
13768 loop {
13769 if shared.shutdown.load(Ordering::Acquire) {
13770 return Ok(());
13771 }
13772 let permit = tokio::select! {
13773 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13774 permit.map_err(|_| NodeServerError::PreauthClosed)?
13775 }
13776 _ = shared.shutdown_notify.notified() => return Ok(()),
13777 };
13778 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13779 Ok(()) => {
13780 failures = 0;
13784 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13785 }
13786 Err(error) => {
13787 failures = failures.saturating_add(1);
13788 tracing::warn!(
13789 node_id = %shared.node_id,
13790 %relay,
13791 cause = %error,
13792 attempt = failures,
13793 "call-home connection failed",
13794 );
13795 let backoff = call_home_backoff(failures);
13796 tokio::select! {
13797 _ = sleep(backoff) => {}
13798 _ = shared.shutdown_notify.notified() => return Ok(()),
13799 }
13800 }
13801 }
13802 }
13803}
13804
13805const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13807const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13809
13810fn call_home_keepalive() -> socket2::TcpKeepalive {
13811 let keepalive = socket2::TcpKeepalive::new()
13812 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13813 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13814 #[cfg(not(windows))]
13815 let keepalive = keepalive.with_retries(3);
13816 keepalive
13817}
13818
13819fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13820 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13821}
13822
13823pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13825 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13826}
13827
13828fn call_home_backoff(attempt: usize) -> Duration {
13830 Duration::from_millis(
13831 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13832 )
13833}
13834
13835
13836async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13839 #[cfg(feature = "wireguard")]
13840 {
13841 let std_stream = tokio::task::spawn_blocking(move || {
13842 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13843 })
13844 .await
13845 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13846 .map_err(NodeServerError::CallHomeTransport)?;
13847 std_stream
13848 .set_nonblocking(true)
13849 .map_err(NodeServerError::CallHomeTransport)?;
13850 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13851 }
13852 #[cfg(not(feature = "wireguard"))]
13853 {
13854 tokio::net::TcpStream::connect(relay)
13855 .await
13856 .map_err(NodeServerError::CallHomeTransport)
13857 }
13858}
13859
13860async fn dial_relay_and_serve(
13862 relay: std::net::SocketAddr,
13863 identity: Option<&hatchery_enroll::NodeIdentity>,
13864 shared: Arc<NodeShared>,
13865 preauth_permit: OwnedSemaphorePermit,
13866) -> Result<(), NodeServerError> {
13867 let mut stream = dial_call_home(relay).await?;
13868 let _ = stream.set_nodelay(true);
13872 if let Err(error) = enable_call_home_keepalive(&stream) {
13878 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13879 }
13880 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13881 .await
13882 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13883 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13884 let mut session_token = None;
13885 if let Some(identity) = identity {
13886 let mut magic = [0_u8; 4];
13890 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13891 .await
13892 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13893 .map_err(NodeServerError::CallHomeTransport)?;
13894 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13895 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13896 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13897 .await
13898 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13899 .map_err(NodeServerError::CallHomeTransport)?;
13900 let (answer, token) = identity
13901 .answer_challenge(shared.node_id.as_str(), &frame)
13902 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13903 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13904 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13905 session_token = Some(token);
13906 }
13907 }
13908 serve_connection(stream, shared, session_token, preauth_permit).await
13909}
13910
13911async fn accept_connections_inner(
13912 endpoint: &str,
13913 shared: Arc<NodeShared>,
13914 connections: &mut JoinSet<Result<(), NodeServerError>>,
13915) -> Result<(), NodeServerError> {
13916 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13917 loop {
13918 while connections.try_join_next().is_some() {}
13919 if shared.shutdown.load(Ordering::Acquire) {
13920 return Ok(());
13921 }
13922 let preauth_permit = tokio::select! {
13923 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13924 permit.map_err(|_| NodeServerError::PreauthClosed)?
13925 }
13926 _ = shared.shutdown_notify.notified() => return Ok(()),
13927 };
13928 let server = tokio::select! {
13929 result = listener.accept() => result?,
13930 _ = shared.shutdown_notify.notified() => return Ok(()),
13931 };
13932 let connection_shared = Arc::clone(&shared);
13933 connections.spawn(async move {
13934 serve_connection(server, connection_shared, None, preauth_permit).await
13935 });
13936 if shared.shutdown.load(Ordering::Acquire) {
13937 return Ok(());
13938 }
13939 }
13940}
13941
13942async fn serve_connection<S>(
13950 mut pipe: S,
13951 shared: Arc<NodeShared>,
13952 session_token: Option<String>,
13953 preauth_permit: OwnedSemaphorePermit,
13954) -> Result<(), NodeServerError>
13955where
13956 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13957{
13958 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13960 let hello_frame = timeout(
13961 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13962 read_json_frame_limited_body_timeout(
13963 &mut pipe,
13964 MAX_NODE_HELLO_FRAME_BYTES,
13965 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13966 ),
13967 )
13968 .await
13969 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13970 let ClientFrame::Hello(hello) = hello_frame else {
13971 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13972 };
13973 if hello.build_stamp != BUILD_STAMP {
13974 return Err(NodeServerError::Handshake(format!(
13975 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13976 hello.build_stamp,
13977 )));
13978 }
13979 let compatibility = match hello.compatibility.as_ref() {
13980 Some(offer) => {
13981 let mut selected = node_compatibility_support(&shared)?
13982 .negotiate(offer)
13983 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13984 project_negotiated_provider_ids(&mut selected);
13985 Some(selected)
13986 }
13987 None => None,
13988 };
13989 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13990 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13991 (Some(offer), Some(selected)) => negotiated_auth_proof(
13992 auth_token,
13993 AuthDirection::Server,
13994 hello.role,
13995 &hello.client_nonce,
13996 &server_nonce,
13997 offer,
13998 selected,
13999 ),
14000 (None, None) => auth_proof(
14001 auth_token,
14002 AuthDirection::Server,
14003 hello.role,
14004 &hello.client_nonce,
14005 &server_nonce,
14006 ),
14007 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14008 }
14009 .map_err(NodeServerError::Authentication)?;
14010 write_json_frame_limited(
14011 &mut pipe,
14012 &ServerFrame::Challenge(ServerChallenge {
14013 build_stamp: BUILD_STAMP.to_owned(),
14014 server_nonce,
14015 server_proof,
14016 compatibility: compatibility.clone(),
14017 }),
14018 MAX_NODE_HELLO_FRAME_BYTES,
14019 )
14020 .await?;
14021 let authentication = timeout(
14022 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
14023 read_json_frame_limited_body_timeout(
14024 &mut pipe,
14025 MAX_NODE_HELLO_FRAME_BYTES,
14026 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14027 ),
14028 )
14029 .await
14030 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14031 let ClientFrame::Authenticate(authentication) = authentication else {
14032 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14033 };
14034 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14035 (Some(offer), Some(selected)) => negotiated_auth_proof(
14036 auth_token,
14037 AuthDirection::Client,
14038 hello.role,
14039 &hello.client_nonce,
14040 &server_nonce,
14041 offer,
14042 selected,
14043 ),
14044 (None, None) => auth_proof(
14045 auth_token,
14046 AuthDirection::Client,
14047 hello.role,
14048 &hello.client_nonce,
14049 &server_nonce,
14050 ),
14051 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14052 }
14053 .map_err(NodeServerError::Authentication)?;
14054 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14055 return Err(NodeServerError::Handshake("access denied".to_owned()));
14056 }
14057 let selected_capabilities = compatibility
14058 .as_ref()
14059 .map(|selected| selected.capabilities.clone())
14060 .unwrap_or_default();
14061 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14062 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14063 });
14064 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14065 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14066 });
14067 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14068 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14069 });
14070 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14071 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14072 });
14073 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14074 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14075 });
14076 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14077 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14078 }) && selected_capabilities.iter().any(|capability| {
14079 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14080 });
14081 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14082 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14083 });
14084 let include_session_bundles = selected_capabilities.iter().any(|capability| {
14085 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14086 });
14087 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14088 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14089 });
14090 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14091 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14092 });
14093 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14094 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14095 });
14096 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14097 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14098 });
14099 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14100 .try_acquire_owned()
14101 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14102 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14103 let _release_guard = ControllerReleaseGuard {
14104 shared: Arc::clone(&shared),
14105 connection_id,
14106 };
14107 drop(preauth_permit);
14108 let _authenticated_permit = authenticated_permit;
14109 let mut event_rx = shared.event_tx.subscribe();
14110 let mut terminal_event_rx = terminal_event_subscription(
14111 &shared,
14112 include_terminal_frame_events,
14113 );
14114 let mut agent_stream_event_rx = agent_stream_event_subscription(
14115 &shared,
14116 include_agent_stream_events,
14117 );
14118 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14119 &shared,
14120 include_harness_mcp_proxy,
14121 );
14122 write_json_frame(
14123 &mut pipe,
14124 &ServerFrame::Hello(NodeHello {
14125 build_stamp: BUILD_STAMP.to_owned(),
14126 incarnation_id: shared.incarnation_id,
14127 connection_id,
14128 role: hello.role,
14129 event_sequence: shared.current_sequence(),
14130 controller: shared.controller_state(),
14131 snapshot: snapshot_for_wire(
14132 &shared,
14133 include_provider_runtime_status,
14134 include_open_provider_ids,
14135 include_spawn_profiles,
14136 include_managed_worktrees,
14137 include_child_environment_profiles,
14138 include_session_bundles,
14139 include_history_context_packs,
14140 include_agent_progress,
14141 include_session_task_correlation,
14142 ),
14143 compatibility,
14144 }),
14145 )
14146 .await?;
14147 let (mut reader, mut writer) = tokio::io::split(pipe);
14148 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14149 let reader_task = tokio::spawn(async move {
14150 loop {
14151 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14152 &mut reader,
14153 MAX_NODE_CLIENT_FRAME_BYTES,
14154 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14155 )
14156 .await;
14157 let terminal = frame.is_err();
14158 if frame_tx.send(frame).await.is_err() || terminal {
14159 break;
14160 }
14161 }
14162 });
14163 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14164 let mut pending_events = Vec::new();
14165 let mut resync_required = false;
14166 let mut discard_events_through = 0_u64;
14167 loop {
14168 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14173 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14174 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14175 for _ in 0..durable_drain_budget {
14176 match event_rx.try_recv() {
14177 Ok(event) => queue_connection_event(
14178 &mut pending_events,
14179 event,
14180 discard_events_through,
14181 ),
14182 Err(broadcast::error::TryRecvError::Empty) => break,
14183 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14184 resync_required = true;
14185 break;
14186 }
14187 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14188 }
14189 }
14190 if let Some(receiver) = terminal_event_rx.as_mut() {
14191 match receiver.try_recv() {
14192 Ok(events) => queue_connection_event_batch(
14193 &mut pending_events,
14194 &events,
14195 discard_events_through,
14196 ),
14197 Err(broadcast::error::TryRecvError::Empty) => {}
14198 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14199 resync_required = true;
14200 }
14201 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14202 }
14203 }
14204 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14205 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14215 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14216 for _ in 0..agent_stream_drain_budget {
14217 match receiver.try_recv() {
14218 Ok(events) => queue_connection_event_batch(
14219 &mut pending_events,
14220 &events,
14221 discard_events_through,
14222 ),
14223 Err(broadcast::error::TryRecvError::Empty) => break,
14224 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14225 tracing::warn!(
14226 connection_id,
14227 dropped,
14228 "agent stream event subscription lagged; forcing a resync",
14229 );
14230 resync_required = true;
14231 break;
14232 }
14233 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14234 }
14235 }
14236 }
14237 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14238 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14244 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14245 for _ in 0..harness_mcp_proxy_drain_budget {
14246 match receiver.try_recv() {
14247 Ok(events) => queue_connection_event_batch(
14248 &mut pending_events,
14249 &events,
14250 discard_events_through,
14251 ),
14252 Err(broadcast::error::TryRecvError::Empty) => break,
14253 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14254 tracing::warn!(
14255 connection_id,
14256 dropped,
14257 "harness MCP proxy event subscription lagged; forcing a resync",
14258 );
14259 resync_required = true;
14260 break;
14261 }
14262 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14263 }
14264 }
14265 }
14266 if resync_required {
14267 let event = resync_required_event(&shared);
14268 apply_connection_resync_watermark(
14269 &mut pending_events,
14270 &mut discard_events_through,
14271 event.sequence,
14272 );
14273 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14274 resync_required = false;
14275 continue;
14276 }
14277 if !pending_events.is_empty() {
14278 pending_events.sort_unstable_by_key(|event| event.sequence);
14279 let burst_len = connection_event_burst_len(pending_events.len());
14280 shared
14281 .connection_events_sent
14282 .fetch_add(burst_len as u64, Ordering::Relaxed);
14283 for event in pending_events.drain(..burst_len) {
14284 let event = if include_managed_worktrees {
14285 Some(event)
14286 } else {
14287 project_event_without_managed_worktrees(event)
14288 };
14289 let event = event.and_then(|event| if include_open_provider_ids {
14290 Some(event)
14291 } else {
14292 project_event_legacy_provider_ids(&shared, event)
14293 });
14294 let event = event.map(|event| if include_child_environment_profiles {
14295 event
14296 } else {
14297 project_event_without_child_environment_profile(event)
14298 });
14299 let event = event.map(|event| if include_session_bundles {
14300 event
14301 } else {
14302 project_event_without_session_bundle(event)
14303 });
14304 let event = event.map(|event| {
14305 project_event_history_for_wire(event, include_history_context_packs)
14306 });
14307 let event = event.map(|event| if include_history_context_packs {
14308 event
14309 } else {
14310 project_event_without_context_pack(event)
14311 });
14312 let event = event.map(|event| if include_session_task_correlation {
14313 event
14314 } else {
14315 project_event_without_session_task_binding(event)
14316 });
14317 let exact_harness_controller = hello.role == ClientRole::Operator
14318 && shared.controller_state().is_some_and(|controller| {
14319 controller.connection_id == connection_id
14320 });
14321 let event = event.and_then(|event| {
14322 if include_harness_mcp_proxy && exact_harness_controller {
14323 Some(event)
14324 } else {
14325 project_event_without_harness_mcp_proxy(event)
14326 }
14327 });
14328 if let Some(event) = event {
14329 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14330 }
14331 }
14332 }
14333 tokio::select! {
14334 biased;
14335 frame = frame_rx.recv() => {
14336 let frame = match frame {
14337 Some(frame) => frame,
14338 None => break,
14339 };
14340 let frame = match frame {
14341 Ok(frame) => frame,
14342 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14343 Err(error) => return Err(error.into()),
14344 };
14345 let ClientFrame::Request(request) = frame else {
14346 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14347 };
14348 #[cfg(feature = "fixture")]
14349 let probe_spawn_managed_worktree_v2 = matches!(
14350 &request.request,
14351 NodeRequest::SpawnManagedWorktreeV2 { .. }
14352 );
14353 let requires_open_provider_ids =
14354 request_requires_open_provider_ids(&shared, &request.request);
14355 let requires_child_environment_profile =
14356 request_requires_child_environment_profile(&shared, &request.request);
14357 let requires_session_bundle =
14358 request_requires_session_bundle(&shared, &request.request);
14359 let requires_history_context_pack =
14360 request_requires_history_context_pack(&shared, &request.request);
14361 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14362 ResponseEnvelope {
14363 request_id: request.request_id,
14364 result: Err(failure(
14365 NodeFailureCode::UnsupportedCapability,
14366 "open provider IDs were not negotiated",
14367 )),
14368 }
14369 } else if requires_child_environment_profile
14370 && !include_child_environment_profiles
14371 {
14372 ResponseEnvelope {
14373 request_id: request.request_id,
14374 result: Err(failure(
14375 NodeFailureCode::UnsupportedCapability,
14376 "child environment profiles were not negotiated",
14377 )),
14378 }
14379 } else if requires_session_bundle && !include_session_bundles {
14380 ResponseEnvelope {
14381 request_id: request.request_id,
14382 result: Err(failure(
14383 NodeFailureCode::UnsupportedCapability,
14384 "session bundles were not negotiated",
14385 )),
14386 }
14387 } else if requires_history_context_pack && !include_history_context_packs {
14388 ResponseEnvelope {
14389 request_id: request.request_id,
14390 result: Err(failure(
14391 NodeFailureCode::UnsupportedCapability,
14392 "history context packs were not negotiated",
14393 )),
14394 }
14395 } else if request_uses_unnegotiated_capability(
14396 &request.request,
14397 &selected_capabilities,
14398 )
14399 {
14400 ResponseEnvelope {
14401 request_id: request.request_id,
14402 result: Err(failure(
14403 NodeFailureCode::UnsupportedCapability,
14404 "request capability was not negotiated",
14405 )),
14406 }
14407 } else {
14408 process_request(&shared, connection_id, hello.role, request).await
14409 };
14410 #[cfg(feature = "fixture")]
14411 if probe_spawn_managed_worktree_v2 {
14412 shared
14413 .fixture_spawn_managed_worktree_v2_failure_probe
14414 .record(reply.result.as_ref().err().map(|failure| failure.code));
14415 }
14416 if !include_provider_runtime_status {
14417 clear_response_provider_runtime_status(&mut reply);
14418 }
14419 if !include_managed_worktrees {
14420 project_response_without_managed_worktrees(&mut reply);
14421 }
14422 if !include_open_provider_ids {
14423 project_response_legacy_provider_ids(&shared, &mut reply);
14424 }
14425 if !include_child_environment_profiles {
14426 project_response_without_child_environment_profile(&mut reply);
14427 }
14428 if !include_session_bundles {
14429 project_response_without_session_bundle(&mut reply);
14430 }
14431 project_response_history_for_wire(
14432 &mut reply,
14433 include_history_context_packs,
14434 );
14435 if !include_history_context_packs {
14436 project_response_without_context_pack(&mut reply);
14437 }
14438 if !include_agent_progress {
14439 project_response_without_agent_progress(&mut reply);
14440 }
14441 if !include_session_task_correlation {
14442 project_response_without_session_task_binding(&mut reply);
14443 }
14444 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14445 }
14446 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14447 event = event_rx.recv() => {
14448 match event {
14449 Ok(event) => queue_connection_event(
14450 &mut pending_events,
14451 event,
14452 discard_events_through,
14453 ),
14454 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14455 Err(broadcast::error::RecvError::Closed) => break,
14456 }
14457 }
14458 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14459 match events {
14460 Ok(events) => queue_connection_event_batch(
14461 &mut pending_events,
14462 &events,
14463 discard_events_through,
14464 ),
14465 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14466 Err(broadcast::error::RecvError::Closed) => break,
14467 }
14468 }
14469 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14470 match events {
14471 Ok(events) => queue_connection_event_batch(
14472 &mut pending_events,
14473 &events,
14474 discard_events_through,
14475 ),
14476 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14477 Err(broadcast::error::RecvError::Closed) => break,
14478 }
14479 }
14480 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14481 match events {
14482 Ok(events) => queue_connection_event_batch(
14483 &mut pending_events,
14484 &events,
14485 discard_events_through,
14486 ),
14487 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14488 Err(broadcast::error::RecvError::Closed) => break,
14489 }
14490 }
14491 _ = shared.shutdown_notify.notified() => break,
14492 }
14493 }
14494 Ok(())
14495}
14496
14497async fn receive_terminal_event_batch(
14498 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14499) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14500 match receiver {
14501 Some(receiver) => receiver.recv().await,
14502 None => std::future::pending().await,
14503 }
14504}
14505
14506fn terminal_event_subscription(
14507 shared: &NodeShared,
14508 enabled: bool,
14509) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14510 enabled.then(|| shared.terminal_event_tx.subscribe())
14511}
14512
14513fn agent_stream_event_subscription(
14517 shared: &NodeShared,
14518 enabled: bool,
14519) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14520 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14521}
14522
14523fn harness_mcp_proxy_event_subscription(
14528 shared: &NodeShared,
14529 enabled: bool,
14530) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14531 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14532}
14533
14534fn queue_connection_event(
14535 pending: &mut Vec<NodeEventEnvelope>,
14536 event: NodeEventEnvelope,
14537 discard_through: u64,
14538) {
14539 if event.sequence <= discard_through
14540 && !event.event.requires_harness_mcp_proxy_capability()
14541 {
14542 return;
14543 }
14544 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14545 if let Some(index) = pending.iter().position(|current| {
14546 matches!(
14547 ¤t.event,
14548 NodeEvent::TerminalFrame {
14549 address: current_address,
14550 ..
14551 } if current_address == address
14552 )
14553 }) {
14554 pending.remove(index);
14555 }
14556 }
14557 pending.push(event);
14558}
14559
14560fn project_event_without_harness_mcp_proxy(
14561 envelope: NodeEventEnvelope,
14562) -> Option<NodeEventEnvelope> {
14563 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14564}
14565
14566fn queue_connection_event_batch(
14567 pending: &mut Vec<NodeEventEnvelope>,
14568 events: &[NodeEventEnvelope],
14569 discard_through: u64,
14570) {
14571 for event in events {
14572 queue_connection_event(pending, event.clone(), discard_through);
14573 }
14574}
14575
14576fn apply_connection_resync_watermark(
14577 pending: &mut Vec<NodeEventEnvelope>,
14578 discard_through: &mut u64,
14579 marker_sequence: u64,
14580) {
14581 *discard_through = (*discard_through).max(marker_sequence);
14582 pending.retain(|event| event.sequence > *discard_through);
14583}
14584
14585fn connection_event_burst_len(pending_len: usize) -> usize {
14586 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14587}
14588
14589fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14590 let history = shared
14591 .history
14592 .lock()
14593 .unwrap_or_else(|poisoned| poisoned.into_inner());
14594 let sequence = history.last_sequence;
14595 let oldest_available_sequence = history.replay_floor_sequence;
14596 NodeEventEnvelope {
14597 sequence,
14598 event: NodeEvent::ResyncRequired {
14599 oldest_available_sequence,
14600 },
14601 }
14602}
14603
14604fn node_compatibility_support(
14605 shared: &NodeShared,
14606) -> Result<NodeCompatibilitySupport, NodeServerError> {
14607 let mut support = node_compatibility_support_for_manifest(
14608 &shared.provider_contracts,
14609 &shared.provider_adapter_contracts,
14610 )?;
14611 if shared
14612 .delivery_store
14613 .lock()
14614 .unwrap_or_else(|poisoned| poisoned.into_inner())
14615 .is_some()
14616 {
14617 support.capabilities.push(
14618 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14619 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14620 );
14621 }
14622 if shared.harness_mcp_registry.is_some() {
14623 support.capabilities.push(
14624 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14625 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14626 );
14627 }
14628 Ok(support)
14629}
14630
14631fn node_compatibility_support_for_manifest(
14632 provider_contracts: &[ProviderContractSupport],
14633 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14634) -> Result<NodeCompatibilitySupport, NodeServerError> {
14635 Ok(NodeCompatibilitySupport {
14636 build_stamp: BUILD_STAMP.to_owned(),
14637 capabilities: baseline_capabilities()?,
14638 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14639 path_semantics: platform::path_semantics(),
14640 local_transport: platform::local_transport(),
14641 state_schema: StateSchemaSupport {
14642 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14643 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14644 },
14645 provider_contracts: provider_contracts.to_vec(),
14646 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14647 })
14648}
14649
14650fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14651 let capabilities = [
14652 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14653 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14654 NODE_REPOSITORY_PATH_CAPABILITY,
14655 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14656 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14657 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14658 NODE_GIT_READ_CAPABILITY,
14659 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14660 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14661 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14662 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14663 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14664 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14665 NODE_ACP_CONTROL_CAPABILITY,
14666 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14667 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14668 NODE_WORKTREE_SELECTION_CAPABILITY,
14669 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14670 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14671 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14672 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14673 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14674 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14675 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14676 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14677 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14678 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14679 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14680 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14681 ]
14682 .into_iter()
14683 .map(|capability| {
14684 CapabilityId::new(capability)
14685 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14686 })
14687 .collect::<Result<Vec<_>, _>>()?;
14688 #[cfg(windows)]
14689 let capabilities = {
14690 let mut capabilities = capabilities;
14691 capabilities.push(
14692 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14693 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14694 );
14695 capabilities
14696 };
14697 Ok(capabilities)
14698}
14699
14700fn request_uses_unnegotiated_capability(
14701 request: &NodeRequest,
14702 selected_capabilities: &[CapabilityId],
14703) -> bool {
14704 let selected = |required: &str| {
14705 selected_capabilities
14706 .iter()
14707 .any(|capability| capability.as_str() == required)
14708 };
14709 request
14710 .required_capability()
14711 .is_some_and(|required| !selected(required))
14712 || (request.requires_worktree_selection_capability()
14713 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14714 || (request.requires_spawn_spec_defaults_overrides_capability()
14715 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14716 || (request.requires_spawn_profile_revision_capability()
14717 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14718 || (request.requires_child_environment_profile_capability()
14719 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14720 || (request.requires_session_bundle_materialization_capability()
14721 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14722 || (request.requires_history_context_pack_capability()
14723 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14724}
14725
14726fn snapshot_for_wire(
14727 shared: &NodeShared,
14728 include_provider_runtime_status: bool,
14729 include_open_provider_ids: bool,
14730 include_spawn_profiles: bool,
14731 include_managed_worktrees: bool,
14732 include_child_environment_profiles: bool,
14733 include_session_bundles: bool,
14734 include_history_context_packs: bool,
14735 include_agent_progress: bool,
14736 include_session_task_correlation: bool,
14737) -> NodeSnapshot {
14738 let mut snapshot = shared.snapshot();
14739 if !include_provider_runtime_status {
14740 snapshot.provider_runtime_statuses.clear();
14741 }
14742 if !include_open_provider_ids {
14743 project_snapshot_legacy_provider_ids(&mut snapshot);
14744 }
14745 if !include_managed_worktrees {
14746 snapshot.managed_worktrees.clear();
14747 for workspace in &mut snapshot.workspaces {
14748 workspace.managed_worktree_profiles = None;
14749 }
14750 }
14751 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14752 if !include_spawn_profiles {
14753 inventory.spawn_profiles = None;
14754 }
14755 if !include_session_bundles {
14756 inventory.bundles = None;
14757 }
14758 if inventory.spawn_profiles.is_none()
14759 && inventory.bundles.is_none()
14760 && inventory.network_allowlists.is_none()
14761 {
14762 snapshot.launch_inventory = None;
14763 }
14764 }
14765 if !include_child_environment_profiles {
14766 clear_snapshot_child_environment_profiles(&mut snapshot);
14767 }
14768 if !include_session_bundles {
14769 clear_snapshot_session_bundles(&mut snapshot);
14770 }
14771 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14772 if !include_history_context_packs {
14773 clear_snapshot_context_packs(&mut snapshot);
14774 }
14775 if !include_agent_progress {
14776 snapshot.agent_progress.clear();
14777 }
14778 if !include_session_task_correlation {
14779 clear_snapshot_session_task_bindings(&mut snapshot);
14780 }
14781 snapshot
14782}
14783
14784fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14785 for record in &mut snapshot.session_records {
14786 record.task_binding = None;
14787 }
14788}
14789
14790fn project_event_without_session_task_binding(
14791 mut envelope: NodeEventEnvelope,
14792) -> NodeEventEnvelope {
14793 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14794 record.task_binding = None;
14795 }
14796 envelope
14797}
14798
14799fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14800 let Ok(response) = reply.result.as_mut() else { return };
14801 match response {
14802 NodeResponse::Snapshot { snapshot, .. } => {
14803 clear_snapshot_session_task_bindings(snapshot);
14804 }
14805 NodeResponse::Resync { snapshot, events, .. } => {
14806 clear_snapshot_session_task_bindings(snapshot);
14807 for event in events {
14808 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14809 record.task_binding = None;
14810 }
14811 }
14812 }
14813 NodeResponse::SessionRecordUpdated { record }
14814 | NodeResponse::ProviderSessionIndexed { record }
14815 | NodeResponse::NativeSessionIndexed { record, .. }
14816 | NodeResponse::SessionRecordResumed { record, .. } => {
14817 record.task_binding = None;
14818 }
14819 _ => {}
14820 }
14821}
14822
14823fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14824 let Ok(response) = reply.result.as_mut() else { return };
14825 match response {
14826 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14827 snapshot.agent_progress.clear();
14828 }
14829 _ => {}
14830 }
14831}
14832
14833fn project_event_without_managed_worktrees(
14834 mut envelope: NodeEventEnvelope,
14835) -> Option<NodeEventEnvelope> {
14836 if matches!(
14837 envelope.event,
14838 NodeEvent::ManagedWorktreeUpserted { .. }
14839 | NodeEvent::ManagedWorktreeRemoved { .. }
14840 ) {
14841 return None;
14842 }
14843 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14844 workspace.managed_worktree_profiles = None;
14845 }
14846 Some(envelope)
14847}
14848
14849fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14850 if matches!(
14851 reply.result,
14852 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14853 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14854 ) {
14855 reply.result = Err(failure(
14856 NodeFailureCode::UnsupportedCapability,
14857 "managed worktree capability was not negotiated",
14858 ));
14859 return;
14860 }
14861 let Ok(response) = reply.result.as_mut() else { return };
14862 match response {
14863 NodeResponse::Snapshot { snapshot, .. } => {
14864 snapshot.managed_worktrees.clear();
14865 for workspace in &mut snapshot.workspaces {
14866 workspace.managed_worktree_profiles = None;
14867 }
14868 }
14869 NodeResponse::Resync { snapshot, events, .. } => {
14870 snapshot.managed_worktrees.clear();
14871 for workspace in &mut snapshot.workspaces {
14872 workspace.managed_worktree_profiles = None;
14873 }
14874 *events = std::mem::take(events)
14875 .into_iter()
14876 .filter_map(project_event_without_managed_worktrees)
14877 .collect();
14878 }
14879 NodeResponse::WorkspaceRegistered { workspace }
14880 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14881 | NodeResponse::WorktreeCreated { workspace, .. } => {
14882 workspace.managed_worktree_profiles = None;
14883 }
14884 NodeResponse::WorkspaceInspected { inspection } => {
14885 inspection.git.managed_worktree = None;
14886 }
14887 _ => {}
14888 }
14889}
14890
14891fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14892 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14893 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14894 });
14895 if includes_open_provider_ids {
14896 return;
14897 }
14898 compatibility
14899 .provider_contracts
14900 .retain(|contract| provider_id_is_legacy(&contract.provider));
14901 compatibility
14902 .provider_adapter_contracts
14903 .retain(|contract| provider_id_is_legacy(&contract.provider));
14904}
14905
14906fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14907 if let Some(spec) = match request {
14908 NodeRequest::SpawnSpec { spec }
14909 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14910 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14911 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14912 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14913 _ => None,
14914 } {
14915 return shared
14916 .resolve_spawn_spec(spec)
14917 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14918 }
14919 request_requires_open_provider_ids_with(
14920 request,
14921 |session| shared.validate_address(session).ok(),
14922 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14923 )
14924}
14925
14926fn request_requires_child_environment_profile(
14927 shared: &NodeShared,
14928 request: &NodeRequest,
14929) -> bool {
14930 if let Some(spec) = match request {
14931 NodeRequest::SpawnSpec { spec }
14932 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14933 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14934 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14935 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14936 _ => None,
14937 } {
14938 return shared
14939 .resolve_spawn_spec(spec)
14940 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14941 }
14942 match request {
14943 NodeRequest::Resume { session, .. }
14944 | NodeRequest::Prompt { session, .. }
14945 | NodeRequest::Paste { session, .. }
14946 | NodeRequest::Input { session, .. }
14947 | NodeRequest::TerminalBytes { session, .. }
14948 | NodeRequest::TerminalControl { session, .. }
14949 | NodeRequest::Resize { session, .. }
14950 | NodeRequest::Interrupt { session }
14951 | NodeRequest::Stop { session, .. }
14952 | NodeRequest::Remove { session }
14953 | NodeRequest::ResolveInteraction { session, .. }
14954 | NodeRequest::SetSessionMode { session, .. }
14955 | NodeRequest::SetSessionConfigOption { session, .. }
14956 | NodeRequest::SetSessionModel { session, .. }
14957 | NodeRequest::DiscoverHistory { session, .. }
14958 | NodeRequest::LoadHistory { session, .. }
14959 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14960 | NodeRequest::ExportContextPack { session } => shared
14961 .session_bindings
14962 .lock()
14963 .unwrap_or_else(|poisoned| poisoned.into_inner())
14964 .get(&session.session.instance_id)
14965 .filter(|binding| {
14966 binding.workspace_id == session.workspace_id
14967 && binding.generation == session.session.generation
14968 })
14969 .map_or(true, |binding| binding.environment_profile.is_some()),
14970 NodeRequest::RenameSessionRecord { record_id, .. }
14971 | NodeRequest::SetSessionTask { record_id, .. }
14972 | NodeRequest::ResumeSessionRecord { record_id, .. }
14973 | NodeRequest::ForgetSessionRecord { record_id } => shared
14974 .record(record_id)
14975 .map_or(true, |record| record.environment_profile.is_some()),
14976 NodeRequest::ArmHarnessMcpReservation { .. }
14977 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14978 | NodeRequest::ActivateHarnessMcpReservation { .. }
14979 | NodeRequest::AbortHarnessMcpReservation { .. }
14980 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14981 | NodeRequest::RejectHarnessMcpCall { .. }
14982 | NodeRequest::Snapshot
14983 | NodeRequest::Resync { .. }
14984 | NodeRequest::BeginDeliveryStage { .. }
14985 | NodeRequest::PutDeliveryBlobChunk { .. }
14986 | NodeRequest::CommitDeliveryStage { .. }
14987 | NodeRequest::AbortDeliveryStage { .. }
14988 | NodeRequest::BrowseHostDirectories { .. }
14989 | NodeRequest::InspectWorkspace { .. }
14990 | NodeRequest::ReadWorkspaceFile { .. }
14991 | NodeRequest::WriteWorkspaceFile { .. }
14992 | NodeRequest::CreateWorkspaceFile { .. }
14993 | NodeRequest::CreateWorkspaceDirectory { .. }
14994 | NodeRequest::ReadGitHistory { .. }
14995 | NodeRequest::ReadGitDiff { .. }
14996 | NodeRequest::AcquireController { .. }
14997 | NodeRequest::ReleaseController
14998 | NodeRequest::Spawn { .. }
14999 | NodeRequest::SpawnWithOptions { .. }
15000 | NodeRequest::SpawnSpec { .. }
15001 | NodeRequest::RegisterWorkspace { .. }
15002 | NodeRequest::CreateStandaloneWorkspace { .. }
15003 | NodeRequest::UnregisterWorkspace { .. }
15004 | NodeRequest::CreateWorktree { .. }
15005 | NodeRequest::RemoveWorktree { .. }
15006 | NodeRequest::SpawnManagedWorktree { .. }
15007 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15008 | NodeRequest::CleanupManagedWorktree { .. }
15009 | NodeRequest::IndexProviderSession { .. }
15010 | NodeRequest::IndexNativeSession { .. }
15011 | NodeRequest::CatalogNativeSessions { .. }
15012 | NodeRequest::PageNativeSessions { .. }
15013 | NodeRequest::PreviewNativeSession { .. }
15014 | NodeRequest::PreviewSessionRecord { .. }
15015 | NodeRequest::ForgetContextPack { .. }
15016 | NodeRequest::ResolveDurableContextPack { .. }
15017 | NodeRequest::ReadContextPack { .. }
15018 | NodeRequest::Shutdown => false,
15019 }
15020}
15021
15022fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
15023 if let Some(spec) = match request {
15024 NodeRequest::SpawnSpec { spec }
15025 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15026 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15027 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15028 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15029 _ => None,
15030 } {
15031 return shared
15032 .resolve_spawn_spec(spec)
15033 .is_ok_and(|resolved| resolved.bundle_id.is_some());
15034 }
15035 match request {
15036 NodeRequest::Resume { session, .. }
15037 | NodeRequest::Prompt { session, .. }
15038 | NodeRequest::Paste { session, .. }
15039 | NodeRequest::Input { session, .. }
15040 | NodeRequest::TerminalBytes { session, .. }
15041 | NodeRequest::TerminalControl { session, .. }
15042 | NodeRequest::Resize { session, .. }
15043 | NodeRequest::Interrupt { session }
15044 | NodeRequest::Stop { session, .. }
15045 | NodeRequest::Remove { session }
15046 | NodeRequest::ResolveInteraction { session, .. }
15047 | NodeRequest::SetSessionMode { session, .. }
15048 | NodeRequest::SetSessionConfigOption { session, .. }
15049 | NodeRequest::SetSessionModel { session, .. }
15050 | NodeRequest::DiscoverHistory { session, .. }
15051 | NodeRequest::LoadHistory { session, .. }
15052 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15053 | NodeRequest::ExportContextPack { session } => shared
15054 .session_bindings
15055 .lock()
15056 .unwrap_or_else(|poisoned| poisoned.into_inner())
15057 .get(&session.session.instance_id)
15058 .filter(|binding| {
15059 binding.workspace_id == session.workspace_id
15060 && binding.generation == session.session.generation
15061 })
15062 .map_or(true, |binding| binding.bundle.is_some()),
15063 NodeRequest::RenameSessionRecord { record_id, .. }
15064 | NodeRequest::SetSessionTask { record_id, .. }
15065 | NodeRequest::ResumeSessionRecord { record_id, .. }
15066 | NodeRequest::ForgetSessionRecord { record_id } => shared
15067 .record(record_id)
15068 .map_or(true, |record| record.bundle.is_some()),
15069 NodeRequest::ArmHarnessMcpReservation { .. }
15070 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15071 | NodeRequest::ActivateHarnessMcpReservation { .. }
15072 | NodeRequest::AbortHarnessMcpReservation { .. }
15073 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15074 | NodeRequest::RejectHarnessMcpCall { .. }
15075 | NodeRequest::Snapshot
15076 | NodeRequest::Resync { .. }
15077 | NodeRequest::BeginDeliveryStage { .. }
15078 | NodeRequest::PutDeliveryBlobChunk { .. }
15079 | NodeRequest::CommitDeliveryStage { .. }
15080 | NodeRequest::AbortDeliveryStage { .. }
15081 | NodeRequest::BrowseHostDirectories { .. }
15082 | NodeRequest::InspectWorkspace { .. }
15083 | NodeRequest::ReadWorkspaceFile { .. }
15084 | NodeRequest::WriteWorkspaceFile { .. }
15085 | NodeRequest::CreateWorkspaceFile { .. }
15086 | NodeRequest::CreateWorkspaceDirectory { .. }
15087 | NodeRequest::ReadGitHistory { .. }
15088 | NodeRequest::ReadGitDiff { .. }
15089 | NodeRequest::AcquireController { .. }
15090 | NodeRequest::ReleaseController
15091 | NodeRequest::Spawn { .. }
15092 | NodeRequest::SpawnWithOptions { .. }
15093 | NodeRequest::SpawnSpec { .. }
15094 | NodeRequest::RegisterWorkspace { .. }
15095 | NodeRequest::CreateStandaloneWorkspace { .. }
15096 | NodeRequest::UnregisterWorkspace { .. }
15097 | NodeRequest::CreateWorktree { .. }
15098 | NodeRequest::RemoveWorktree { .. }
15099 | NodeRequest::SpawnManagedWorktree { .. }
15100 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15101 | NodeRequest::CleanupManagedWorktree { .. }
15102 | NodeRequest::IndexProviderSession { .. }
15103 | NodeRequest::IndexNativeSession { .. }
15104 | NodeRequest::CatalogNativeSessions { .. }
15105 | NodeRequest::PageNativeSessions { .. }
15106 | NodeRequest::PreviewNativeSession { .. }
15107 | NodeRequest::PreviewSessionRecord { .. }
15108 | NodeRequest::ForgetContextPack { .. }
15109 | NodeRequest::ResolveDurableContextPack { .. }
15110 | NodeRequest::ReadContextPack { .. }
15111 | NodeRequest::Shutdown => false,
15112 }
15113}
15114
15115fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15116 if request.requires_history_context_pack_capability() {
15117 return true;
15118 }
15119 if let Some(spec) = match request {
15120 NodeRequest::SpawnSpec { spec }
15121 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15122 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15123 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15124 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15125 _ => None,
15126 } {
15127 return shared
15128 .resolve_spawn_spec(spec)
15129 .map_or(true, |resolved| resolved.context_id.is_some());
15130 }
15131 match request {
15132 NodeRequest::Resume { session, .. }
15133 | NodeRequest::Prompt { session, .. }
15134 | NodeRequest::Paste { session, .. }
15135 | NodeRequest::Input { session, .. }
15136 | NodeRequest::TerminalBytes { session, .. }
15137 | NodeRequest::TerminalControl { session, .. }
15138 | NodeRequest::Resize { session, .. }
15139 | NodeRequest::Interrupt { session }
15140 | NodeRequest::Stop { session, .. }
15141 | NodeRequest::Remove { session }
15142 | NodeRequest::ResolveInteraction { session, .. }
15143 | NodeRequest::SetSessionMode { session, .. }
15144 | NodeRequest::SetSessionConfigOption { session, .. }
15145 | NodeRequest::SetSessionModel { session, .. } => shared
15146 .session_bindings
15147 .lock()
15148 .unwrap_or_else(|poisoned| poisoned.into_inner())
15149 .get(&session.session.instance_id)
15150 .filter(|binding| {
15151 binding.workspace_id == session.workspace_id
15152 && binding.generation == session.session.generation
15153 })
15154 .map_or(true, |binding| binding.context.is_some()),
15155 NodeRequest::RenameSessionRecord { record_id, .. }
15156 | NodeRequest::SetSessionTask { record_id, .. }
15157 | NodeRequest::ResumeSessionRecord { record_id, .. }
15158 | NodeRequest::ForgetSessionRecord { record_id } => shared
15159 .record(record_id)
15160 .map_or(true, |record| record.context.is_some()),
15161 NodeRequest::ArmHarnessMcpReservation { .. }
15162 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15163 | NodeRequest::ActivateHarnessMcpReservation { .. }
15164 | NodeRequest::AbortHarnessMcpReservation { .. }
15165 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15166 | NodeRequest::RejectHarnessMcpCall { .. }
15167 | NodeRequest::Snapshot
15168 | NodeRequest::Resync { .. }
15169 | NodeRequest::BeginDeliveryStage { .. }
15170 | NodeRequest::PutDeliveryBlobChunk { .. }
15171 | NodeRequest::CommitDeliveryStage { .. }
15172 | NodeRequest::AbortDeliveryStage { .. }
15173 | NodeRequest::BrowseHostDirectories { .. }
15174 | NodeRequest::InspectWorkspace { .. }
15175 | NodeRequest::ReadWorkspaceFile { .. }
15176 | NodeRequest::WriteWorkspaceFile { .. }
15177 | NodeRequest::CreateWorkspaceFile { .. }
15178 | NodeRequest::CreateWorkspaceDirectory { .. }
15179 | NodeRequest::ReadGitHistory { .. }
15180 | NodeRequest::ReadGitDiff { .. }
15181 | NodeRequest::AcquireController { .. }
15182 | NodeRequest::ReleaseController
15183 | NodeRequest::Spawn { .. }
15184 | NodeRequest::SpawnWithOptions { .. }
15185 | NodeRequest::SpawnSpec { .. }
15186 | NodeRequest::RegisterWorkspace { .. }
15187 | NodeRequest::CreateStandaloneWorkspace { .. }
15188 | NodeRequest::UnregisterWorkspace { .. }
15189 | NodeRequest::CreateWorktree { .. }
15190 | NodeRequest::RemoveWorktree { .. }
15191 | NodeRequest::SpawnManagedWorktree { .. }
15192 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15193 | NodeRequest::CleanupManagedWorktree { .. }
15194 | NodeRequest::IndexProviderSession { .. }
15195 | NodeRequest::IndexNativeSession { .. }
15196 | NodeRequest::CatalogNativeSessions { .. }
15197 | NodeRequest::PageNativeSessions { .. }
15198 | NodeRequest::PreviewNativeSession { .. }
15199 | NodeRequest::PreviewSessionRecord { .. }
15200 | NodeRequest::DiscoverHistory { .. }
15201 | NodeRequest::LoadHistory { .. }
15202 | NodeRequest::ExportContextPackForSessionRecord { .. }
15203 | NodeRequest::ExportContextPack { .. }
15204 | NodeRequest::ForgetContextPack { .. }
15205 | NodeRequest::ResolveDurableContextPack { .. }
15206 | NodeRequest::ReadContextPack { .. }
15207 | NodeRequest::Shutdown => false,
15208 }
15209}
15210
15211fn request_requires_open_provider_ids_with(
15212 request: &NodeRequest,
15213 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15214 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15215) -> bool {
15216 match request {
15217 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15218 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15219 NodeRequest::CatalogNativeSessions { route, .. }
15220 | NodeRequest::PageNativeSessions { route, .. } => {
15221 !provider_id_is_legacy(&route.provider)
15222 }
15223 NodeRequest::PreviewNativeSession { selection, .. }
15224 | NodeRequest::IndexNativeSession { selection, .. } => {
15225 !provider_id_is_legacy(&selection.route.provider)
15226 }
15227 NodeRequest::SpawnSpec { .. }
15228 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15229 NodeRequest::SpawnManagedWorktree { .. }
15230 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15231 NodeRequest::Resume { session, .. }
15232 | NodeRequest::Prompt { session, .. }
15233 | NodeRequest::Paste { session, .. }
15234 | NodeRequest::Input { session, .. }
15235 | NodeRequest::TerminalBytes { session, .. }
15236 | NodeRequest::TerminalControl { session, .. }
15237 | NodeRequest::Resize { session, .. }
15238 | NodeRequest::Interrupt { session }
15239 | NodeRequest::Stop { session, .. }
15240 | NodeRequest::Remove { session }
15241 | NodeRequest::ResolveInteraction { session, .. }
15242 | NodeRequest::SetSessionMode { session, .. }
15243 | NodeRequest::SetSessionConfigOption { session, .. }
15244 | NodeRequest::SetSessionModel { session, .. }
15245 | NodeRequest::DiscoverHistory { session, .. }
15246 | NodeRequest::LoadHistory { session, .. }
15247 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15248 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15249 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15250 NodeRequest::RenameSessionRecord { record_id, .. }
15251 | NodeRequest::SetSessionTask { record_id, .. }
15252 | NodeRequest::ResumeSessionRecord { record_id, .. }
15253 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15254 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15255 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15256 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15257 NodeRequest::ArmHarnessMcpReservation { .. }
15258 | NodeRequest::ActivateHarnessMcpReservation { .. }
15259 | NodeRequest::AbortHarnessMcpReservation { .. }
15260 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15261 | NodeRequest::RejectHarnessMcpCall { .. }
15262 | NodeRequest::Snapshot
15263 | NodeRequest::Resync { .. }
15264 | NodeRequest::BeginDeliveryStage { .. }
15265 | NodeRequest::PutDeliveryBlobChunk { .. }
15266 | NodeRequest::CommitDeliveryStage { .. }
15267 | NodeRequest::AbortDeliveryStage { .. }
15268 | NodeRequest::BrowseHostDirectories { .. }
15269 | NodeRequest::InspectWorkspace { .. }
15270 | NodeRequest::ReadWorkspaceFile { .. }
15271 | NodeRequest::WriteWorkspaceFile { .. }
15272 | NodeRequest::CreateWorkspaceFile { .. }
15273 | NodeRequest::CreateWorkspaceDirectory { .. }
15274 | NodeRequest::ReadGitHistory { .. }
15275 | NodeRequest::ReadGitDiff { .. }
15276 | NodeRequest::AcquireController { .. }
15277 | NodeRequest::ReleaseController
15278 | NodeRequest::RegisterWorkspace { .. }
15279 | NodeRequest::CreateStandaloneWorkspace { .. }
15280 | NodeRequest::UnregisterWorkspace { .. }
15281 | NodeRequest::CreateWorktree { .. }
15282 | NodeRequest::RemoveWorktree { .. }
15283 | NodeRequest::CleanupManagedWorktree { .. }
15284 | NodeRequest::Shutdown => false,
15285 NodeRequest::ForgetContextPack { .. } => true,
15286 NodeRequest::ResolveDurableContextPack { .. }
15292 | NodeRequest::ReadContextPack { .. } => true,
15293 }
15294}
15295
15296fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15297 snapshot.enabled_providers.retain(provider_id_is_legacy);
15298 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15299 snapshot
15300 .provider_runtime_statuses
15301 .as_slice()
15302 .iter()
15303 .filter(|status| provider_id_is_legacy(status.provider()))
15304 .cloned(),
15305 )
15306 .expect("a filtered provider runtime projection remains valid");
15307 snapshot
15308 .session_records
15309 .retain(|record| provider_id_is_legacy(&record.provider));
15310 for workspace in &mut snapshot.workspaces {
15311 workspace
15312 .sessions
15313 .retain(|session| provider_id_is_legacy(&session.agent_id));
15314 }
15315}
15316
15317fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15318 for record in &mut snapshot.session_records {
15319 record.environment_profile = None;
15320 }
15321 if let Some(profiles) = snapshot
15322 .launch_inventory
15323 .as_mut()
15324 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15325 {
15326 for profile in profiles {
15327 profile.environment_profile = None;
15328 }
15329 }
15330}
15331
15332fn project_event_without_child_environment_profile(
15333 mut envelope: NodeEventEnvelope,
15334) -> NodeEventEnvelope {
15335 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15336 record.environment_profile = None;
15337 }
15338 envelope
15339}
15340
15341fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15342 let contains_environment_profile = match reply.result.as_ref() {
15343 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15344 receipt.environment_profile.is_some()
15345 }
15346 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15347 receipt.spawn.environment_profile.is_some()
15348 }
15349 _ => false,
15350 };
15351 if contains_environment_profile {
15352 reply.result = Err(failure(
15353 NodeFailureCode::UnsupportedCapability,
15354 "child environment profiles were not negotiated",
15355 ));
15356 return;
15357 }
15358 let Ok(response) = reply.result.as_mut() else {
15359 return;
15360 };
15361 match response {
15362 NodeResponse::Snapshot { snapshot, .. } => {
15363 clear_snapshot_child_environment_profiles(snapshot);
15364 }
15365 NodeResponse::Resync { snapshot, events, .. } => {
15366 clear_snapshot_child_environment_profiles(snapshot);
15367 for event in events {
15368 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15369 record.environment_profile = None;
15370 }
15371 }
15372 }
15373 NodeResponse::SessionRecordUpdated { record }
15374 | NodeResponse::ProviderSessionIndexed { record }
15375 | NodeResponse::NativeSessionIndexed { record, .. }
15376 | NodeResponse::SessionRecordResumed { record, .. } => {
15377 record.environment_profile = None;
15378 }
15379 NodeResponse::Armed { .. }
15380 | NodeResponse::Spawned { .. }
15381 | NodeResponse::Activated { .. }
15382 | NodeResponse::Aborted { .. }
15383 | NodeResponse::ReplyChunkAccepted { .. }
15384 | NodeResponse::CallRejected { .. }
15385 | NodeResponse::DeliveryStageBegun { .. }
15386 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15387 | NodeResponse::DeliveryCommitted { .. }
15388 | NodeResponse::DeliveryStageAborted { .. }
15389 | NodeResponse::WorkspaceInspected { .. }
15390 | NodeResponse::HostDirectoriesBrowsed { .. }
15391 | NodeResponse::WorkspaceFileRead { .. }
15392 | NodeResponse::WorkspaceFileWritten { .. }
15393 | NodeResponse::WorkspaceFileCreated { .. }
15394 | NodeResponse::WorkspaceDirectoryCreated { .. }
15395 | NodeResponse::GitHistoryRead { .. }
15396 | NodeResponse::GitDiffRead { .. }
15397 | NodeResponse::Controller { .. }
15398 | NodeResponse::SpawnAccepted { .. }
15399 | NodeResponse::SpawnSpecAccepted { .. }
15400 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15401 | NodeResponse::ManagedWorktreeCleanup { .. }
15402 | NodeResponse::NativeSessionsCataloged { .. }
15403 | NodeResponse::NativeSessionsPaged { .. }
15404 | NodeResponse::NativeSessionPreviewed { .. }
15405 | NodeResponse::SessionRecordPreviewed { .. }
15406 | NodeResponse::HistoryDiscovered { .. }
15407 | NodeResponse::HistoryLoaded { .. }
15408 | NodeResponse::ContextPackForSessionRecordExported { .. }
15409 | NodeResponse::ContextPackExported { .. }
15410 | NodeResponse::ContextPackForgotten { .. }
15411 | NodeResponse::DurableContextPackResolved { .. }
15412 | NodeResponse::ContextPackBytesRead { .. }
15413 | NodeResponse::SessionRecordForgotten { .. }
15414 | NodeResponse::WorkspaceRegistered { .. }
15415 | NodeResponse::StandaloneWorkspaceCreated { .. }
15416 | NodeResponse::WorkspaceUnregistered { .. }
15417 | NodeResponse::WorktreeCreated { .. }
15418 | NodeResponse::WorktreeRemoved { .. }
15419 | NodeResponse::Accepted
15420 | NodeResponse::ShuttingDown => {}
15421 }
15422}
15423
15424fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15425 for record in &mut snapshot.session_records {
15426 record.bundle = None;
15427 }
15428}
15429
15430fn project_event_without_session_bundle(
15431 mut envelope: NodeEventEnvelope,
15432) -> NodeEventEnvelope {
15433 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15434 record.bundle = None;
15435 }
15436 envelope
15437}
15438
15439fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15440 let contains_bundle = match reply.result.as_ref() {
15441 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15442 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15443 receipt.spawn.bundle.is_some()
15444 }
15445 _ => false,
15446 };
15447 if contains_bundle {
15448 reply.result = Err(failure(
15449 NodeFailureCode::UnsupportedCapability,
15450 "session bundles were not negotiated",
15451 ));
15452 return;
15453 }
15454 let Ok(response) = reply.result.as_mut() else {
15455 return;
15456 };
15457 match response {
15458 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15459 NodeResponse::Resync { snapshot, events, .. } => {
15460 clear_snapshot_session_bundles(snapshot);
15461 for event in events {
15462 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15463 record.bundle = None;
15464 }
15465 }
15466 }
15467 NodeResponse::SessionRecordUpdated { record }
15468 | NodeResponse::ProviderSessionIndexed { record }
15469 | NodeResponse::NativeSessionIndexed { record, .. }
15470 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15471 NodeResponse::Armed { .. }
15472 | NodeResponse::Spawned { .. }
15473 | NodeResponse::Activated { .. }
15474 | NodeResponse::Aborted { .. }
15475 | NodeResponse::ReplyChunkAccepted { .. }
15476 | NodeResponse::CallRejected { .. }
15477 | NodeResponse::DeliveryStageBegun { .. }
15478 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15479 | NodeResponse::DeliveryCommitted { .. }
15480 | NodeResponse::DeliveryStageAborted { .. }
15481 | NodeResponse::WorkspaceInspected { .. }
15482 | NodeResponse::HostDirectoriesBrowsed { .. }
15483 | NodeResponse::WorkspaceFileRead { .. }
15484 | NodeResponse::WorkspaceFileWritten { .. }
15485 | NodeResponse::WorkspaceFileCreated { .. }
15486 | NodeResponse::WorkspaceDirectoryCreated { .. }
15487 | NodeResponse::GitHistoryRead { .. }
15488 | NodeResponse::GitDiffRead { .. }
15489 | NodeResponse::Controller { .. }
15490 | NodeResponse::SpawnAccepted { .. }
15491 | NodeResponse::SpawnSpecAccepted { .. }
15492 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15493 | NodeResponse::ManagedWorktreeCleanup { .. }
15494 | NodeResponse::NativeSessionsCataloged { .. }
15495 | NodeResponse::NativeSessionsPaged { .. }
15496 | NodeResponse::NativeSessionPreviewed { .. }
15497 | NodeResponse::SessionRecordPreviewed { .. }
15498 | NodeResponse::HistoryDiscovered { .. }
15499 | NodeResponse::HistoryLoaded { .. }
15500 | NodeResponse::ContextPackForSessionRecordExported { .. }
15501 | NodeResponse::ContextPackExported { .. }
15502 | NodeResponse::ContextPackForgotten { .. }
15503 | NodeResponse::DurableContextPackResolved { .. }
15504 | NodeResponse::ContextPackBytesRead { .. }
15505 | NodeResponse::SessionRecordForgotten { .. }
15506 | NodeResponse::WorkspaceRegistered { .. }
15507 | NodeResponse::StandaloneWorkspaceCreated { .. }
15508 | NodeResponse::WorkspaceUnregistered { .. }
15509 | NodeResponse::WorktreeCreated { .. }
15510 | NodeResponse::WorktreeRemoved { .. }
15511 | NodeResponse::Accepted
15512 | NodeResponse::ShuttingDown => {}
15513 }
15514}
15515
15516fn project_snapshot_history_for_wire(
15517 snapshot: &mut NodeSnapshot,
15518 include_history_metadata: bool,
15519) {
15520 for workspace in &mut snapshot.workspaces {
15521 for session in &mut workspace.sessions {
15522 if include_history_metadata {
15523 session.history.loaded = None;
15524 } else {
15525 session.history = Default::default();
15526 }
15527 }
15528 }
15529}
15530
15531fn project_event_history_for_wire(
15532 mut envelope: NodeEventEnvelope,
15533 include_history_metadata: bool,
15534) -> NodeEventEnvelope {
15535 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15536 for session in &mut workspace.sessions {
15537 if include_history_metadata {
15538 session.history.loaded = None;
15539 } else {
15540 session.history = Default::default();
15541 }
15542 }
15543 }
15544 envelope
15545}
15546
15547fn project_response_history_for_wire(
15548 reply: &mut ResponseEnvelope,
15549 include_history_metadata: bool,
15550) {
15551 let Ok(response) = reply.result.as_mut() else {
15552 return;
15553 };
15554 match response {
15555 NodeResponse::Snapshot { snapshot, .. } => {
15556 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15557 }
15558 NodeResponse::Resync {
15559 snapshot, events, ..
15560 } => {
15561 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15562 for event in events {
15563 *event = project_event_history_for_wire(
15564 event.clone(),
15565 include_history_metadata,
15566 );
15567 }
15568 }
15569 NodeResponse::WorkspaceRegistered { workspace }
15570 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15571 | NodeResponse::WorktreeCreated { workspace, .. } => {
15572 for session in &mut workspace.sessions {
15573 if include_history_metadata {
15574 session.history.loaded = None;
15575 } else {
15576 session.history = Default::default();
15577 }
15578 }
15579 }
15580 _ => {}
15581 }
15582}
15583
15584fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15585 for record in &mut snapshot.session_records {
15586 record.context_id = None;
15587 record.context = None;
15588 record.exported_context = None;
15589 }
15590}
15591
15592fn project_event_without_context_pack(
15593 mut envelope: NodeEventEnvelope,
15594) -> NodeEventEnvelope {
15595 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15596 record.context_id = None;
15597 record.context = None;
15598 record.exported_context = None;
15599 }
15600 envelope
15601}
15602
15603fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15604 let contains_context = match reply.result.as_ref() {
15605 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15606 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15607 receipt.spawn.context.is_some()
15608 }
15609 Ok(NodeResponse::HistoryDiscovered { .. })
15610 | Ok(NodeResponse::HistoryLoaded { .. })
15611 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15612 | Ok(NodeResponse::ContextPackExported { .. })
15613 | Ok(NodeResponse::ContextPackForgotten { .. })
15614 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15615 _ => false,
15616 };
15617 if contains_context {
15618 reply.result = Err(failure(
15619 NodeFailureCode::UnsupportedCapability,
15620 "history context packs were not negotiated",
15621 ));
15622 return;
15623 }
15624 let Ok(response) = reply.result.as_mut() else {
15625 return;
15626 };
15627 match response {
15628 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15629 NodeResponse::Resync {
15630 snapshot, events, ..
15631 } => {
15632 clear_snapshot_context_packs(snapshot);
15633 for event in events {
15634 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15635 record.context_id = None;
15636 record.context = None;
15637 record.exported_context = None;
15638 }
15639 }
15640 }
15641 NodeResponse::SessionRecordUpdated { record }
15642 | NodeResponse::ProviderSessionIndexed { record }
15643 | NodeResponse::NativeSessionIndexed { record, .. }
15644 | NodeResponse::SessionRecordResumed { record, .. } => {
15645 record.context_id = None;
15646 record.context = None;
15647 record.exported_context = None;
15648 }
15649 _ => {}
15650 }
15651}
15652
15653fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15654 let contains_open_record = match reply.result.as_ref() {
15655 Ok(NodeResponse::SessionRecordUpdated { record })
15656 | Ok(NodeResponse::ProviderSessionIndexed { record })
15657 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15658 !provider_id_is_legacy(&record.provider)
15659 }
15660 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15661 !provider_id_is_legacy(&selection.route.provider)
15662 || !provider_id_is_legacy(&record.provider)
15663 }
15664 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15665 !provider_id_is_legacy(&receipt.provider)
15666 }
15667 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15668 !provider_id_is_legacy(&receipt.spawn.provider)
15669 }
15670 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15671 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15672 !provider_id_is_legacy(&route.provider)
15673 }
15674 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15675 !provider_id_is_legacy(&selection.route.provider)
15676 }
15677 _ => false,
15678 };
15679 if contains_open_record {
15680 reply.result = Err(failure(
15681 NodeFailureCode::UnsupportedCapability,
15682 "open provider IDs were not negotiated",
15683 ));
15684 return;
15685 }
15686 let Ok(response) = reply.result.as_mut() else {
15687 return;
15688 };
15689 match response {
15690 NodeResponse::Snapshot { snapshot, .. } => {
15691 project_snapshot_legacy_provider_ids(snapshot);
15692 }
15693 NodeResponse::Resync { snapshot, events, .. } => {
15694 project_snapshot_legacy_provider_ids(snapshot);
15695 *events = std::mem::take(events)
15696 .into_iter()
15697 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15698 .collect();
15699 }
15700 NodeResponse::WorkspaceRegistered { workspace }
15701 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15702 | NodeResponse::WorktreeCreated { workspace, .. } => {
15703 workspace
15704 .sessions
15705 .retain(|session| provider_id_is_legacy(&session.agent_id));
15706 }
15707 NodeResponse::Armed { .. }
15708 | NodeResponse::Spawned { .. }
15709 | NodeResponse::Activated { .. }
15710 | NodeResponse::Aborted { .. }
15711 | NodeResponse::ReplyChunkAccepted { .. }
15712 | NodeResponse::CallRejected { .. }
15713 | NodeResponse::DeliveryStageBegun { .. }
15714 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15715 | NodeResponse::DeliveryCommitted { .. }
15716 | NodeResponse::DeliveryStageAborted { .. }
15717 | NodeResponse::WorkspaceInspected { .. }
15718 | NodeResponse::HostDirectoriesBrowsed { .. }
15719 | NodeResponse::WorkspaceFileRead { .. }
15720 | NodeResponse::WorkspaceFileWritten { .. }
15721 | NodeResponse::WorkspaceFileCreated { .. }
15722 | NodeResponse::WorkspaceDirectoryCreated { .. }
15723 | NodeResponse::GitHistoryRead { .. }
15724 | NodeResponse::GitDiffRead { .. }
15725 | NodeResponse::Controller { .. }
15726 | NodeResponse::SpawnAccepted { .. }
15727 | NodeResponse::SpawnSpecAccepted { .. }
15728 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15729 | NodeResponse::ManagedWorktreeCleanup { .. }
15730 | NodeResponse::NativeSessionsCataloged { .. }
15731 | NodeResponse::NativeSessionsPaged { .. }
15732 | NodeResponse::NativeSessionPreviewed { .. }
15733 | NodeResponse::SessionRecordPreviewed { .. }
15734 | NodeResponse::HistoryDiscovered { .. }
15735 | NodeResponse::HistoryLoaded { .. }
15736 | NodeResponse::ContextPackForSessionRecordExported { .. }
15737 | NodeResponse::ContextPackExported { .. }
15738 | NodeResponse::ContextPackForgotten { .. }
15739 | NodeResponse::DurableContextPackResolved { .. }
15740 | NodeResponse::ContextPackBytesRead { .. }
15741 | NodeResponse::SessionRecordUpdated { .. }
15742 | NodeResponse::ProviderSessionIndexed { .. }
15743 | NodeResponse::NativeSessionIndexed { .. }
15744 | NodeResponse::SessionRecordResumed { .. }
15745 | NodeResponse::SessionRecordForgotten { .. }
15746 | NodeResponse::WorkspaceUnregistered { .. }
15747 | NodeResponse::WorktreeRemoved { .. }
15748 | NodeResponse::Accepted
15749 | NodeResponse::ShuttingDown => {}
15750 }
15751}
15752
15753fn project_event_legacy_provider_ids(
15754 shared: &NodeShared,
15755 mut envelope: NodeEventEnvelope,
15756) -> Option<NodeEventEnvelope> {
15757 let include = match &mut envelope.event {
15758 NodeEvent::HarnessMcpReadCall { .. } => true,
15759 NodeEvent::Control { address, .. }
15760 | NodeEvent::AgentStream { address, .. }
15761 | NodeEvent::TerminalFrame { address, .. } => shared
15762 .validate_address(address)
15763 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15764 NodeEvent::WorkspaceAdded { workspace } => {
15765 workspace
15766 .sessions
15767 .retain(|session| provider_id_is_legacy(&session.agent_id));
15768 true
15769 }
15770 NodeEvent::SessionRecordUpserted { record } => {
15771 provider_id_is_legacy(&record.provider)
15772 }
15773 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15774 NodeEvent::SessionRecordRemoved { .. } => shared
15775 .history
15776 .lock()
15777 .unwrap_or_else(|poisoned| poisoned.into_inner())
15778 .removed_record_providers
15779 .get(&envelope.sequence)
15780 .is_some_and(provider_id_is_legacy),
15781 NodeEvent::ControllerChanged { .. }
15782 | NodeEvent::ManagedWorktreeUpserted { .. }
15783 | NodeEvent::ManagedWorktreeRemoved { .. }
15784 | NodeEvent::WorkspaceRemoved { .. }
15785 | NodeEvent::ResyncRequired { .. } => true,
15786 };
15787 include.then_some(envelope)
15788}
15789
15790fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15791 let Ok(response) = reply.result.as_mut() else {
15792 return;
15793 };
15794 match response {
15795 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15796 snapshot.provider_runtime_statuses.clear();
15797 }
15798 NodeResponse::Armed { .. }
15799 | NodeResponse::Spawned { .. }
15800 | NodeResponse::Activated { .. }
15801 | NodeResponse::Aborted { .. }
15802 | NodeResponse::ReplyChunkAccepted { .. }
15803 | NodeResponse::CallRejected { .. }
15804 | NodeResponse::DeliveryStageBegun { .. }
15805 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15806 | NodeResponse::DeliveryCommitted { .. }
15807 | NodeResponse::DeliveryStageAborted { .. }
15808 | NodeResponse::WorkspaceInspected { .. }
15809 | NodeResponse::HostDirectoriesBrowsed { .. }
15810 | NodeResponse::WorkspaceFileRead { .. }
15811 | NodeResponse::WorkspaceFileWritten { .. }
15812 | NodeResponse::WorkspaceFileCreated { .. }
15813 | NodeResponse::WorkspaceDirectoryCreated { .. }
15814 | NodeResponse::GitHistoryRead { .. }
15815 | NodeResponse::GitDiffRead { .. }
15816 | NodeResponse::Controller { .. }
15817 | NodeResponse::SpawnAccepted { .. }
15818 | NodeResponse::SpawnSpecAccepted { .. }
15819 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15820 | NodeResponse::ManagedWorktreeCleanup { .. }
15821 | NodeResponse::NativeSessionsCataloged { .. }
15822 | NodeResponse::NativeSessionsPaged { .. }
15823 | NodeResponse::NativeSessionPreviewed { .. }
15824 | NodeResponse::SessionRecordPreviewed { .. }
15825 | NodeResponse::HistoryDiscovered { .. }
15826 | NodeResponse::HistoryLoaded { .. }
15827 | NodeResponse::ContextPackForSessionRecordExported { .. }
15828 | NodeResponse::ContextPackExported { .. }
15829 | NodeResponse::ContextPackForgotten { .. }
15830 | NodeResponse::DurableContextPackResolved { .. }
15831 | NodeResponse::ContextPackBytesRead { .. }
15832 | NodeResponse::SessionRecordUpdated { .. }
15833 | NodeResponse::ProviderSessionIndexed { .. }
15834 | NodeResponse::NativeSessionIndexed { .. }
15835 | NodeResponse::SessionRecordResumed { .. }
15836 | NodeResponse::SessionRecordForgotten { .. }
15837 | NodeResponse::WorkspaceRegistered { .. }
15838 | NodeResponse::StandaloneWorkspaceCreated { .. }
15839 | NodeResponse::WorkspaceUnregistered { .. }
15840 | NodeResponse::WorktreeCreated { .. }
15841 | NodeResponse::WorktreeRemoved { .. }
15842 | NodeResponse::Accepted
15843 | NodeResponse::ShuttingDown => {}
15844 }
15845}
15846
15847struct ControllerReleaseGuard {
15848 shared: Arc<NodeShared>,
15849 connection_id: u64,
15850}
15851
15852impl Drop for ControllerReleaseGuard {
15853 fn drop(&mut self) {
15854 self.shared.release_controller(self.connection_id);
15855 }
15856}
15857
15858struct AbortTaskOnDrop(AbortHandle);
15859
15860impl Drop for AbortTaskOnDrop {
15861 fn drop(&mut self) {
15862 self.0.abort();
15863 }
15864}
15865
15866fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15871 std::env::var(name)
15872 .ok()
15873 .and_then(|value| value.parse::<u64>().ok())
15874 .filter(|value| *value > 0)
15875 .unwrap_or(default)
15876 .min(max)
15877}
15878
15879fn workspace_inspection_time_budget_ms() -> u64 {
15880 env_config_bounded(
15881 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15882 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15883 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15884 )
15885}
15886
15887fn workspace_inspection_entry_cap() -> usize {
15888 env_config_bounded(
15889 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15890 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15891 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15892 ) as usize
15893}
15894
15895struct WorkspaceWalkBudget {
15901 deadline: Instant,
15902 entry_cap: usize,
15903 entries_visited: u64,
15904 time_budget_exceeded: bool,
15905 entry_cap_exceeded: bool,
15906}
15907
15908impl WorkspaceWalkBudget {
15909 fn new(deadline: Instant, entry_cap: usize) -> Self {
15910 Self {
15911 deadline,
15912 entry_cap,
15913 entries_visited: 0,
15914 time_budget_exceeded: false,
15915 entry_cap_exceeded: false,
15916 }
15917 }
15918
15919 fn is_exhausted(&self) -> bool {
15920 self.time_budget_exceeded || self.entry_cap_exceeded
15921 }
15922
15923 fn record_visit(&mut self) -> bool {
15926 self.entries_visited += 1;
15927 if self.entries_visited as usize >= self.entry_cap {
15928 self.entry_cap_exceeded = true;
15929 }
15930 if Instant::now() >= self.deadline {
15931 self.time_budget_exceeded = true;
15932 }
15933 self.is_exhausted()
15934 }
15935}
15936
15937fn collect_workspace_entries(
15938 root: &Path,
15939 deadline: Instant,
15940 entry_cap: usize,
15941) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15942 let mut entries = Vec::new();
15943 let mut truncated = false;
15944 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15945 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15946 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15950 (entries, truncated, budget)
15951}
15952
15953fn workspace_inspection_truncation(
15957 walk_budget: &WorkspaceWalkBudget,
15958 git_time_budget_exceeded: bool,
15959 elapsed: Duration,
15960) -> Option<WorkspaceInspectionTruncationV1> {
15961 if !walk_budget.time_budget_exceeded
15962 && !walk_budget.entry_cap_exceeded
15963 && !git_time_budget_exceeded
15964 {
15965 return None;
15966 }
15967 Some(WorkspaceInspectionTruncationV1 {
15968 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15969 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15970 git_time_budget_exceeded,
15971 entries_visited: walk_budget.entries_visited,
15972 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15973 })
15974}
15975
15976fn walk_workspace_directory(
15977 directory: &Path,
15978 relative_directory: Option<&RepositoryPath>,
15979 depth: usize,
15980 entries: &mut Vec<WorkspaceEntry>,
15981 truncated: &mut bool,
15982 budget: &mut WorkspaceWalkBudget,
15983) {
15984 if budget.is_exhausted() {
15985 *truncated = true;
15986 return;
15987 }
15988 let read_dir = match std::fs::read_dir(directory) {
15989 Ok(read_dir) => read_dir,
15990 Err(_) => {
15991 *truncated = true;
15992 return;
15993 }
15994 };
15995 let mut children = Vec::new();
15996 for child in read_dir {
15997 match child {
15998 Ok(child) => children.push(child),
15999 Err(_) => *truncated = true,
16000 }
16001 }
16002 children.sort_by_key(|child| child.file_name());
16003 for child in children {
16004 if budget.record_visit() {
16005 *truncated = true;
16006 return;
16007 }
16008 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
16009 *truncated = true;
16010 return;
16011 }
16012 let file_type = match child.file_type() {
16013 Ok(file_type) => file_type,
16014 Err(_) => {
16015 *truncated = true;
16016 continue;
16017 }
16018 };
16019 if file_type.is_symlink() {
16020 continue;
16021 }
16022 let name = match child.file_name().into_string() {
16023 Ok(name) => name,
16024 Err(_) => {
16025 *truncated = true;
16026 continue;
16027 }
16028 };
16029 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16030 continue;
16031 }
16032 let relative_path = match relative_directory {
16033 None => windows_repository_path(name),
16034 Some(parent) => parent
16035 .as_utf8()
16036 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16037 };
16038 let Some(relative_path) = relative_path else {
16039 *truncated = true;
16040 continue;
16041 };
16042 let kind = if file_type.is_dir() {
16043 WorkspaceEntryKind::Directory
16044 } else if file_type.is_file() {
16045 WorkspaceEntryKind::File
16046 } else {
16047 continue;
16048 };
16049 entries.push(WorkspaceEntry {
16050 relative_path: relative_path.clone(),
16051 kind,
16052 });
16053 if file_type.is_dir() {
16054 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16055 walk_workspace_directory(
16056 &child.path(),
16057 Some(&relative_path),
16058 depth + 1,
16059 entries,
16060 truncated,
16061 budget,
16062 );
16063 } else {
16064 *truncated = true;
16065 }
16066 }
16067 }
16068}
16069
16070fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16071 if value.contains('\\') {
16072 return None;
16073 }
16074 RepositoryPath::utf8(value).ok()
16075}
16076
16077fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16078 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16079}
16080
16081fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16090 if name.eq_ignore_ascii_case(".git")
16091 || name.eq_ignore_ascii_case("target")
16092 || name.eq_ignore_ascii_case("node_modules")
16093 {
16094 return true;
16095 }
16096 path.join("CACHEDIR.TAG").is_file()
16097}
16098
16099fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16100 matches!(
16101 status,
16102 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16103 )
16104}
16105
16106#[derive(Clone, Debug, Eq, PartialEq)]
16107enum ContextPackRepositoryHead {
16108 NotRepository,
16109 Unborn,
16110 Commit(String),
16111}
16112
16113#[derive(Clone, Debug, Eq, PartialEq)]
16114struct ContextPackRepositoryObservation {
16115 head: ContextPackRepositoryHead,
16116 git: GitSnapshot,
16117 files: Vec<ContextPackRepositoryFileSource>,
16118}
16119
16120fn stable_context_pack_repository(
16121 first: ContextPackRepositoryObservation,
16122 second: ContextPackRepositoryObservation,
16123) -> Result<ContextPackRepository, NodeFailure> {
16124 if first != second {
16125 return Err(failure(
16126 NodeFailureCode::ContextPackMaterializationFailed,
16127 "context repository changed during bounded capture",
16128 ));
16129 }
16130 Ok(ContextPackRepository::from_sources(
16131 &second.git,
16132 second.files,
16133 ))
16134}
16135
16136fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16143 if Instant::now() < deadline {
16144 return false;
16145 }
16146 append_git_diagnostic(
16147 snapshot,
16148 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16149 );
16150 true
16151}
16152
16153async fn run_git_bounded_with_deadline(
16162 root: &str,
16163 arguments: &[&str],
16164 output_limit: usize,
16165 deadline: Instant,
16166) -> io::Result<GitCommandOutput> {
16167 let remaining = deadline.checked_duration_since(Instant::now());
16168 let timeout_ms = remaining
16169 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16170 .unwrap_or(0)
16171 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16172 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16173}
16174
16175async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16182 let mut snapshot = GitSnapshot {
16183 is_repository: false,
16184 branch: None,
16185 status: Vec::new(),
16186 recent_commits: Vec::new(),
16187 worktrees: Vec::new(),
16188 managed_worktree: None,
16189 truncated: false,
16190 diagnostic: None,
16191 };
16192 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16193 return (snapshot, true);
16194 }
16195 let repository = match run_git_bounded_with_deadline(
16196 root,
16197 &["rev-parse", "--is-inside-work-tree"],
16198 4 * 1_024,
16199 deadline,
16200 )
16201 .await
16202 {
16203 Ok(output) => output,
16204 Err(error) => {
16205 append_git_diagnostic(
16206 &mut snapshot,
16207 &format!("git inspection unavailable: {error}"),
16208 );
16209 return (snapshot, false);
16210 }
16211 };
16212 snapshot.truncated |= repository.truncated;
16213 if repository.timed_out {
16214 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16215 return (snapshot, false);
16216 }
16217 if !repository.success
16218 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16219 {
16220 let stderr = String::from_utf8_lossy(&repository.stderr);
16221 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16222 append_git_diagnostic(&mut snapshot, stderr.trim());
16223 }
16224 return (snapshot, false);
16225 }
16226 snapshot.is_repository = true;
16227
16228 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16229 return (snapshot, true);
16230 }
16231 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16232 Ok(output) => {
16233 snapshot.truncated |= output.truncated;
16234 if output.timed_out {
16235 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16236 } else if output.success {
16237 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16238 if !branch.is_empty() {
16239 snapshot.branch = Some(truncate_git_field(
16240 &branch,
16241 MAX_REPOSITORY_PATH_BYTES,
16242 &mut snapshot.truncated,
16243 ));
16244 }
16245 }
16246 }
16247 Err(error) => append_git_diagnostic(
16248 &mut snapshot,
16249 &format!("git branch query failed: {error}"),
16250 ),
16251 }
16252 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16253 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16254 snapshot.truncated |= output.truncated;
16255 if output.success && !output.timed_out {
16256 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16257 if !head.is_empty() {
16258 snapshot.branch = Some(format!("detached:{head}"));
16259 }
16260 }
16261 }
16262 }
16263
16264 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16265 return (snapshot, true);
16266 }
16267 match run_git_bounded_with_deadline(
16268 root,
16269 &[
16270 "status",
16271 "--porcelain=v1",
16272 "-z",
16273 "--renames",
16274 "--untracked-files=normal",
16275 "--",
16276 ".",
16277 ],
16278 GIT_OUTPUT_MAX_BYTES,
16279 deadline,
16280 )
16281 .await
16282 {
16283 Ok(output) => {
16284 snapshot.truncated |= output.truncated;
16285 if output.timed_out {
16286 append_git_diagnostic(&mut snapshot, "git status timed out");
16287 } else if output.success {
16288 parse_git_status(&output.stdout, &mut snapshot);
16289 } else {
16290 append_git_diagnostic(
16291 &mut snapshot,
16292 String::from_utf8_lossy(&output.stderr).trim(),
16293 );
16294 }
16295 }
16296 Err(error) => append_git_diagnostic(
16297 &mut snapshot,
16298 &format!("git status failed: {error}"),
16299 ),
16300 }
16301
16302 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16303 return (snapshot, true);
16304 }
16305 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16306 match run_git_bounded_with_deadline(
16307 root,
16308 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16309 16 * 1_024,
16310 deadline,
16311 )
16312 .await
16313 {
16314 Ok(output) => {
16315 snapshot.truncated |= output.truncated;
16316 if output.timed_out {
16317 append_git_diagnostic(&mut snapshot, "git log timed out");
16318 } else if output.success {
16319 parse_git_commits(&output.stdout, &mut snapshot);
16320 } else {
16321 let stderr = String::from_utf8_lossy(&output.stderr);
16322 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16323 append_git_diagnostic(&mut snapshot, stderr.trim());
16324 }
16325 }
16326 }
16327 Err(error) => append_git_diagnostic(
16328 &mut snapshot,
16329 &format!("git log failed: {error}"),
16330 ),
16331 }
16332
16333 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16334 return (snapshot, true);
16335 }
16336 match list_git_worktrees_with_deadline(root, deadline).await {
16337 Ok(worktrees) => {
16338 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16339 }
16340 Err(error) => append_git_diagnostic(
16341 &mut snapshot,
16342 &format!("git worktree list failed: {}", error.message),
16343 ),
16344 }
16345 (snapshot, false)
16346}
16347
16348fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16349 let mut remaining = output;
16350 while !remaining.is_empty() {
16351 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16352 snapshot.truncated = true;
16353 break;
16354 }
16355 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16356 snapshot.truncated = true;
16357 break;
16358 };
16359 let record = &remaining[..record_end];
16360 remaining = &remaining[record_end + 1..];
16361 let has_previous_path = record
16362 .get(..2)
16363 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16364 let previous_path_bytes = if has_previous_path {
16365 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16366 snapshot.truncated = true;
16367 break;
16368 };
16369 let previous = &remaining[..previous_end];
16370 remaining = &remaining[previous_end + 1..];
16371 Some(previous)
16372 } else {
16373 None
16374 };
16375 if record.len() < 4
16376 || record[2] != b' '
16377 || !is_git_status_code(record[0])
16378 || !is_git_status_code(record[1])
16379 {
16380 snapshot.truncated = true;
16381 continue;
16382 }
16383 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16384 snapshot.truncated = true;
16385 continue;
16386 };
16387 let previous_path = match previous_path_bytes {
16388 Some(previous) => {
16389 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16390 snapshot.truncated = true;
16391 continue;
16392 };
16393 Some(previous)
16394 }
16395 None => None,
16396 };
16397 snapshot.status.push(GitStatusEntry {
16398 index_status: (record[0] as char).to_string(),
16399 worktree_status: (record[1] as char).to_string(),
16400 path,
16401 previous_path,
16402 });
16403 }
16404}
16405
16406fn is_git_status_code(byte: u8) -> bool {
16407 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16408}
16409
16410fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16411 for line in String::from_utf8_lossy(output).lines() {
16412 let Some((id, summary)) = line.split_once('\u{1f}') else {
16413 continue;
16414 };
16415 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16416 snapshot.truncated = true;
16417 break;
16418 }
16419 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16420 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16421 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16422 }
16423}
16424
16425fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16426 if value.len() <= max_bytes {
16427 return value.to_owned();
16428 }
16429 *truncated = true;
16430 let mut boundary = max_bytes;
16431 while boundary > 0 && !value.is_char_boundary(boundary) {
16432 boundary -= 1;
16433 }
16434 value[..boundary].to_owned()
16435}
16436
16437fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16438 if message.is_empty() {
16439 return;
16440 }
16441 let normalized = message
16442 .chars()
16443 .map(|character| if character.is_control() { ' ' } else { character })
16444 .collect::<String>();
16445 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16446 if !diagnostic.is_empty() {
16447 diagnostic.push_str("; ");
16448 }
16449 diagnostic.push_str(normalized.trim());
16450 snapshot.diagnostic = Some(truncate_git_field(
16451 &diagnostic,
16452 GIT_DIAGNOSTIC_MAX_BYTES,
16453 &mut snapshot.truncated,
16454 ));
16455}
16456
16457async fn run_git_bounded(
16458 root: &str,
16459 arguments: &[&str],
16460 output_limit: usize,
16461) -> io::Result<GitCommandOutput> {
16462 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16463}
16464
16465async fn read_git_history_bounded(
16466 root: &str,
16467 path: Option<&RepositoryPath>,
16468 before: Option<&GitObjectId>,
16469 limit: u16,
16470) -> Result<GitHistoryPage, NodeFailure> {
16471 let path = path
16472 .map(|path| {
16473 path.as_utf8().ok_or_else(|| {
16474 failure(
16475 NodeFailureCode::InvalidRepositoryPath,
16476 "git history path must use UTF-8",
16477 )
16478 })
16479 })
16480 .transpose()?
16481 .unwrap_or(".");
16482 let count = (usize::from(limit) + 1).to_string();
16483 let start = before
16484 .map(|revision| format!("{}^", revision.as_str()))
16485 .unwrap_or_else(|| "HEAD".to_owned());
16486 let arguments = [
16487 "--no-pager",
16488 "log",
16489 "--no-decorate",
16490 "--date=iso-strict",
16491 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16492 "-n",
16493 count.as_str(),
16494 start.as_str(),
16495 "--",
16496 path,
16497 ];
16498 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16499 .await
16500 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16501 if output.timed_out {
16502 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16503 }
16504 if !output.success {
16505 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16506 }
16507 let mut commits = parse_git_history(&output.stdout);
16508 let has_more = commits.len() > usize::from(limit) || output.truncated;
16509 commits.truncate(usize::from(limit));
16510 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16511 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16512}
16513
16514fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16515 String::from_utf8_lossy(output)
16516 .split('\u{1e}')
16517 .filter_map(|record| {
16518 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16519 if fields.len() != 9 {
16520 return None;
16521 }
16522 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16523 let parents = if fields[1].is_empty() {
16524 Vec::new()
16525 } else {
16526 fields[1]
16527 .split(' ')
16528 .map(|value| GitObjectId::new(value.to_owned()))
16529 .collect::<Result<Vec<_>, _>>()
16530 .ok()?
16531 };
16532 Some(GitCommitDetails {
16533 id,
16534 parents,
16535 subject: bounded_git_text(fields[2], 512),
16536 author_name: bounded_git_text(fields[3], 256),
16537 author_email: bounded_git_text(fields[4], 320),
16538 authored_at: bounded_git_text(fields[5], 64),
16539 committer_name: bounded_git_text(fields[6], 256),
16540 committer_email: bounded_git_text(fields[7], 320),
16541 committed_at: bounded_git_text(fields[8], 64),
16542 signature_status: GitSignatureStatus::NoSignature,
16543 signer: None,
16544 })
16545 })
16546 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16547 .collect()
16548}
16549
16550fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16551 let normalized = value
16552 .chars()
16553 .map(|character| if character.is_control() { ' ' } else { character })
16554 .collect::<String>();
16555 if normalized.len() <= max_bytes {
16556 return normalized;
16557 }
16558 let mut boundary = max_bytes;
16559 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16560 boundary -= 1;
16561 }
16562 normalized[..boundary].to_owned()
16563}
16564
16565async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16566 let path = request.path.as_ref().map(|path| {
16567 path.as_utf8()
16568 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16569 }).transpose()?;
16570 let mut arguments = match &request.mode {
16571 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16572 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16573 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16574 };
16575 if let GitDiffMode::Commit { revision } = &request.mode {
16576 arguments.push(revision.as_str());
16577 }
16578 arguments.push("--");
16579 if let Some(path) = path {
16580 arguments.push(path);
16581 }
16582 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16583 .await
16584 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16585 if output.timed_out {
16586 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16587 }
16588 if !output.success {
16589 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16590 }
16591 Ok(GitDiff {
16592 mode: request.mode,
16593 path: request.path,
16594 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16595 truncated: output.truncated,
16596 })
16597}
16598
16599async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16600 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16601 ResponseEnvelope { request_id: envelope.request_id, result }
16602}
16603
16604async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16605 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16606 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16607 }
16608 let read_only = matches!(
16609 &request,
16610 NodeRequest::Snapshot
16611 | NodeRequest::Resync { .. }
16612 | NodeRequest::BrowseHostDirectories { .. }
16613 | NodeRequest::InspectWorkspace { .. }
16614 | NodeRequest::ReadWorkspaceFile { .. }
16615 | NodeRequest::ReadGitHistory { .. }
16616 | NodeRequest::ReadGitDiff { .. }
16617 | NodeRequest::CatalogNativeSessions { .. }
16618 | NodeRequest::PageNativeSessions { .. }
16619 | NodeRequest::PreviewNativeSession { .. }
16620 | NodeRequest::PreviewSessionRecord { .. }
16621 );
16622 let _mutation_guard = if read_only {
16623 None
16624 } else {
16625 let guard = shared.mutation_gate.lock().await;
16626 if shared.shutdown.load(Ordering::Acquire) {
16627 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16628 }
16629 Some(guard)
16630 };
16631 match request {
16632 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16633 event_sequence: shared.current_sequence(),
16634 controller: shared.controller_state(),
16635 snapshot: shared.snapshot(),
16636 }),
16637 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16638 NodeRequest::ArmHarnessMcpReservation {
16639 reservation_id,
16640 activation_digest,
16641 spawn_spec,
16642 launch,
16643 expires_at_unix_ms,
16644 } => {
16645 shared.require_controller(connection_id, role)?;
16646 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16647 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16648 })?;
16649 let expires_at_unix_ms = registry
16650 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16651 .await
16652 .map_err(harness_mcp_failure)?;
16653 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16654 }
16655 NodeRequest::SpawnSpecWithHarnessMcp {
16656 reservation_id,
16657 activation_digest,
16658 spec,
16659 deadline_unix_ms,
16660 } => {
16661 shared.require_controller(connection_id, role)?;
16662 let receipt = shared
16663 .spawn_from_spec_with_harness_mcp(
16664 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16665 )
16666 .await?;
16667 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16668 }
16669 NodeRequest::ActivateHarnessMcpReservation {
16670 reservation_id,
16671 activation_digest,
16672 record_id,
16673 session,
16674 } => {
16675 shared.require_controller(connection_id, role)?;
16676 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16677 current.instance_id == session.session.instance_id
16678 && current.generation == session.session.generation
16679 }).and_then(|current| current.process_id).ok_or_else(|| {
16680 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16681 })?;
16682 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16683 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16684 })?.activate(
16685 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16686 ).map_err(harness_mcp_failure)?;
16687 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16688 }
16689 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16690 shared.require_controller(connection_id, role)?;
16691 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16692 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16693 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16694 if let Some(instance_id) = instance_id {
16695 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16696 }
16697 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16698 }
16699 NodeRequest::PutHarnessMcpReplyChunk {
16700 reservation_id, activation_digest, record_id, session, call_id,
16701 offset, final_chunk, chunk_hex,
16702 } => {
16703 shared.require_controller(connection_id, role)?;
16704 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16705 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16706 })?.put_reply_chunk(
16707 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16708 offset, final_chunk, &chunk_hex,
16709 ).map_err(harness_mcp_failure)?;
16710 Ok(NodeResponse::ReplyChunkAccepted {
16711 reservation_id, activation_digest, record_id, session, call_id,
16712 next_offset, completed,
16713 })
16714 }
16715 NodeRequest::RejectHarnessMcpCall {
16716 reservation_id, activation_digest, record_id, session, call_id, reason,
16717 } => {
16718 shared.require_controller(connection_id, role)?;
16719 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16720 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16721 })?.reject_call(
16722 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16723 ).map_err(harness_mcp_failure)?;
16724 Ok(NodeResponse::CallRejected {
16725 reservation_id, activation_digest, record_id, session, call_id,
16726 })
16727 }
16728 NodeRequest::BeginDeliveryStage { manifest } => {
16729 shared.require_controller(connection_id, role)?;
16730 shared.begin_delivery_stage(manifest)
16731 }
16732 NodeRequest::PutDeliveryBlobChunk {
16733 stage_id,
16734 blob_digest,
16735 offset,
16736 chunk_hex,
16737 } => {
16738 shared.require_controller(connection_id, role)?;
16739 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16740 }
16741 NodeRequest::CommitDeliveryStage { stage_id } => {
16742 shared.require_controller(connection_id, role)?;
16743 let receipt = shared.commit_delivery_stage(stage_id)?;
16744 Ok(NodeResponse::DeliveryCommitted { receipt })
16745 }
16746 NodeRequest::AbortDeliveryStage { stage_id } => {
16747 shared.require_controller(connection_id, role)?;
16748 shared.abort_delivery_stage(&stage_id)?;
16749 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16750 }
16751 NodeRequest::BrowseHostDirectories { directory, after } => {
16752 let listing = shared.browse_host_directories(directory, after).await?;
16753 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16754 }
16755 NodeRequest::InspectWorkspace { workspace_id } => {
16756 let inspection = shared.inspect_workspace(workspace_id).await?;
16757 Ok(NodeResponse::WorkspaceInspected { inspection })
16758 }
16759 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16760 let file = shared.read_workspace_file(workspace_id, path).await?;
16761 Ok(NodeResponse::WorkspaceFileRead { file })
16762 }
16763 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16764 shared.require_controller(connection_id, role)?;
16765 let file = shared
16766 .write_workspace_file(workspace_id, path, expected_revision, text)
16767 .await?;
16768 Ok(NodeResponse::WorkspaceFileWritten { file })
16769 }
16770 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16771 shared.require_controller(connection_id, role)?;
16772 let file = shared.create_workspace_file(workspace_id, path).await?;
16773 Ok(NodeResponse::WorkspaceFileCreated { file })
16774 }
16775 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16776 shared.require_controller(connection_id, role)?;
16777 let entry = shared
16778 .create_workspace_directory(workspace_id.clone(), path)
16779 .await?;
16780 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16781 }
16782 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16783 if role != ClientRole::Operator {
16784 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16785 }
16786 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16787 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16788 }
16789 let page = shared
16790 .read_git_history(workspace_id.clone(), path, before, limit)
16791 .await?;
16792 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16793 }
16794 NodeRequest::ReadGitDiff { workspace_id, request } => {
16795 if role != ClientRole::Operator {
16796 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16797 }
16798 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16799 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16800 }
16801 NodeRequest::CatalogNativeSessions { route, limit } => {
16802 if role != ClientRole::Operator {
16803 return Err(failure(
16804 NodeFailureCode::ObserverReadOnly,
16805 "native session catalog requires an operator connection",
16806 ));
16807 }
16808 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16809 return Err(failure(
16810 NodeFailureCode::InvalidRequest,
16811 "native session catalog limit is invalid",
16812 ));
16813 }
16814 let (entries, summary) = shared
16815 .catalog_native_sessions(route.clone(), limit)
16816 .await?;
16817 Ok(NodeResponse::NativeSessionsCataloged {
16818 route,
16819 entries,
16820 summary: Some(summary),
16821 })
16822 }
16823 NodeRequest::PageNativeSessions {
16824 route,
16825 window,
16826 catalog_revision,
16827 recent_cutoff_unix_ms,
16828 after_selection_id,
16829 limit,
16830 } => {
16831 if role != ClientRole::Operator {
16832 return Err(failure(
16833 NodeFailureCode::ObserverReadOnly,
16834 "native session catalog paging requires an operator connection",
16835 ));
16836 }
16837 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16838 || after_selection_id
16839 .as_deref()
16840 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16841 {
16842 return Err(failure(
16843 NodeFailureCode::InvalidRequest,
16844 "native session catalog page request is invalid",
16845 ));
16846 }
16847 let page = shared
16848 .page_native_sessions(
16849 route.clone(),
16850 window,
16851 catalog_revision,
16852 recent_cutoff_unix_ms,
16853 after_selection_id,
16854 limit,
16855 )
16856 .await?;
16857 Ok(NodeResponse::NativeSessionsPaged {
16858 route,
16859 page,
16860 })
16861 }
16862 NodeRequest::PreviewNativeSession {
16863 selection,
16864 message_limit,
16865 } => {
16866 if role != ClientRole::Operator {
16867 return Err(failure(
16868 NodeFailureCode::ObserverReadOnly,
16869 "native session preview requires an operator connection",
16870 ));
16871 }
16872 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16873 .contains(&message_limit)
16874 || selection.validate().is_err()
16875 {
16876 return Err(failure(
16877 NodeFailureCode::InvalidRequest,
16878 "native session preview request is invalid",
16879 ));
16880 }
16881 let preview = shared
16882 .preview_native_session(selection.clone(), message_limit)
16883 .await?;
16884 Ok(NodeResponse::NativeSessionPreviewed {
16885 selection,
16886 preview,
16887 })
16888 }
16889 NodeRequest::IndexNativeSession {
16890 selection,
16891 display_name,
16892 } => {
16893 shared.require_controller(connection_id, role)?;
16894 let record = shared
16895 .index_native_session(selection.clone(), display_name)
16896 .await?;
16897 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16898 }
16899 NodeRequest::PreviewSessionRecord {
16900 record_id,
16901 message_limit,
16902 } => {
16903 if role != ClientRole::Operator {
16904 return Err(failure(
16905 NodeFailureCode::ObserverReadOnly,
16906 "native session preview requires an operator connection",
16907 ));
16908 }
16909 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16910 .contains(&message_limit)
16911 {
16912 return Err(failure(
16913 NodeFailureCode::InvalidRequest,
16914 "native session preview request is invalid",
16915 ));
16916 }
16917 let record = shared.record(&record_id)?;
16918 let working_directory = shared.workspace_root(&record.workspace_id)?;
16919 if !platform::roots_equal(
16920 &working_directory,
16921 windows_path_text(&record.canonical_root),
16922 ) {
16923 return Err(failure(
16924 NodeFailureCode::SessionRecordConflict,
16925 "managed session workspace root changed; refusing to preview another directory",
16926 ));
16927 }
16928 let identity = record.provider_session.clone().ok_or_else(|| {
16929 failure(
16930 NodeFailureCode::BackendOperationFailed,
16931 "native session preview is unavailable for this session record",
16932 )
16933 })?;
16934 if identity.key != ProviderSessionKey::SessionId {
16935 return Err(failure(
16936 NodeFailureCode::BackendOperationFailed,
16937 "native session preview is unavailable for this session identity",
16938 ));
16939 }
16940 let preview = shared
16941 .preview_native_session_by_session_id(
16942 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16943 )
16944 .await?;
16945 if preview.session_id != identity.id {
16946 return Err(failure(
16947 NodeFailureCode::SessionRecordConflict,
16948 "native session preview identity changed while loading",
16949 ));
16950 }
16951 shared.revalidate_session_record_preview(&record, &identity)?;
16952 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16953 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16954 record_id: record_id.clone(),
16955 summary: SessionHistorySummaryV1::from(&preview),
16956 });
16957 Ok(NodeResponse::SessionRecordPreviewed {
16958 record_id,
16959 preview,
16960 })
16961 }
16962 NodeRequest::AcquireController { lease_ms } => {
16963 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16964 Ok(NodeResponse::Controller { controller: Some(controller) })
16965 }
16966 NodeRequest::ReleaseController => {
16967 shared.release_controller(connection_id);
16968 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16969 }
16970 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16971 shared.require_controller(connection_id, role)?;
16972 let root = require_windows_path(root)?;
16973 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16974 let workspace = shared.register_workspace(workspace_id, root).await?;
16975 Ok(NodeResponse::WorkspaceRegistered { workspace })
16976 }
16977 NodeRequest::CreateStandaloneWorkspace {
16978 workspace_id,
16979 root,
16980 initial_branch,
16981 } => {
16982 shared.require_controller(connection_id, role)?;
16983 let root = require_windows_path(root)?;
16984 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16985 let workspace = shared
16986 .create_standalone_workspace(workspace_id, root, initial_branch)
16987 .await?;
16988 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16989 }
16990 NodeRequest::UnregisterWorkspace { workspace_id } => {
16991 shared.require_controller(connection_id, role)?;
16992 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16993 shared.unregister_workspace(&workspace_id)?;
16994 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16995 }
16996 NodeRequest::CreateWorktree {
16997 source_workspace_id,
16998 workspace_id,
16999 target_root,
17000 branch,
17001 base,
17002 } => {
17003 shared.require_controller(connection_id, role)?;
17004 let target_root = require_windows_path(target_root)?;
17005 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
17006 let (worktree, workspace) = shared
17007 .create_worktree(
17008 source_workspace_id,
17009 workspace_id,
17010 target_root,
17011 branch,
17012 base,
17013 )
17014 .await?;
17015 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
17016 }
17017 NodeRequest::RemoveWorktree {
17018 source_workspace_id,
17019 target_root,
17020 } => {
17021 shared.require_controller(connection_id, role)?;
17022 let native_target_root = require_windows_path(target_root.clone())?;
17023 shared.reject_managed_reservation(None, Some(&native_target_root))?;
17024 let workspace_id = shared
17025 .remove_worktree(source_workspace_id, native_target_root)
17026 .await?;
17027 Ok(NodeResponse::WorktreeRemoved {
17028 target_root,
17029 workspace_id,
17030 })
17031 }
17032 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17033 shared.require_controller(connection_id, role)?;
17034 if !terminal_size.is_valid() {
17035 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17036 }
17037 if let Some(prompt) = initial_prompt.as_deref() {
17038 validate_node_text("spawn initial prompt", prompt)?;
17039 }
17040 match shared
17041 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17042 .await
17043 {
17044 Ok(session) => {
17045 shared.mark_spawn_provisional(session.clone());
17046 Ok(NodeResponse::SpawnAccepted { session })
17047 }
17048 Err(err) => {
17049 tracing::warn!(
17050 %provider,
17051 %workspace_id,
17052 ?mode,
17053 code = ?err.code,
17054 message = %err.message,
17055 "session spawn refused"
17056 );
17057 Err(err)
17058 }
17059 }
17060 }
17061 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17062 shared.require_controller(connection_id, role)?;
17063 if !terminal_size.is_valid() {
17064 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17065 }
17066 if let Some(prompt) = initial_prompt.as_deref() {
17067 validate_node_text("spawn initial prompt", prompt)?;
17068 }
17069 let plan = backend_paths::plan_launch_options(
17070 provider.as_str(),
17071 mode,
17072 options.model.is_some(),
17073 options.effort.is_some(),
17074 )
17075 .map_err(|r| {
17076 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17077 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17078 })?;
17079 let selection = spawn_selection(&options)?;
17080 let nick_model = selection.as_ref().map(|s| s.model.clone());
17081 let pty_selection = match plan {
17082 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17083 _ => None,
17084 };
17085 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17086 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17087 SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17088 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17089 .await
17090 }).await
17091 }).await;
17092 match spawned {
17093 Ok(session) => {
17094 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17095 shared
17096 .pending_acp_options
17097 .lock()
17098 .unwrap_or_else(|p| p.into_inner())
17099 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17100 }
17101 shared.mark_spawn_provisional(session.clone());
17102 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17103 Ok(NodeResponse::SpawnAccepted { session })
17104 }
17105 Err(err) => {
17106 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17107 Err(err)
17108 }
17109 }
17110 }
17111 NodeRequest::SpawnSpec { spec } => {
17112 shared.require_controller(connection_id, role)?;
17113 let receipt = shared.spawn_from_spec(spec).await?;
17114 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17115 }
17116 NodeRequest::SpawnManagedWorktree { request } => {
17117 shared.require_controller(connection_id, role)?;
17118 let receipt = shared.spawn_managed_worktree(request).await?;
17119 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17120 }
17121 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17122 shared.require_controller(connection_id, role)?;
17123 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17124 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17125 }
17126 NodeRequest::CleanupManagedWorktree { lease_id } => {
17127 shared.require_controller(connection_id, role)?;
17128 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17129 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17130 }
17131 NodeRequest::DiscoverHistory { session, limit } => {
17132 controlled_session(shared, connection_id, role, &session)?;
17133 let candidates = shared.discover_history(&session, limit).await?;
17134 Ok(NodeResponse::HistoryDiscovered {
17135 session,
17136 candidates,
17137 })
17138 }
17139 NodeRequest::LoadHistory {
17140 session,
17141 candidate_id,
17142 } => {
17143 controlled_session(shared, connection_id, role, &session)?;
17144 let loaded = shared
17145 .load_history(&session, candidate_id)
17146 .await?;
17147 Ok(NodeResponse::HistoryLoaded {
17148 session,
17149 session_id: loaded.session_id,
17150 message_count: loaded.message_count,
17151 completed_turn_count: loaded.completed_turn_count,
17152 })
17153 }
17154 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17155 shared.require_controller(connection_id, role)?;
17156 let context = shared
17157 .export_context_pack_for_session_record(&record_id, &session, false)
17158 .await?;
17159 Ok(NodeResponse::ContextPackForSessionRecordExported {
17160 record_id,
17161 session,
17162 context,
17163 })
17164 }
17165 NodeRequest::ExportContextPack { session } => {
17166 controlled_session(shared, connection_id, role, &session)?;
17167 let context = shared.export_context_pack(&session).await?;
17168 Ok(NodeResponse::ContextPackExported { context })
17169 }
17170 NodeRequest::ForgetContextPack { context_id } => {
17171 shared.require_controller(connection_id, role)?;
17172 shared.forget_context_pack(&context_id)?;
17173 Ok(NodeResponse::ContextPackForgotten { context_id })
17174 }
17175 NodeRequest::ResolveDurableContextPack { context_id } => {
17176 let context = shared
17180 .context_catalog
17181 .read()
17182 .unwrap_or_else(|poisoned| poisoned.into_inner())
17183 .get(&context_id)
17184 .map(|pack| pack.receipt().clone())
17185 .ok_or_else(|| {
17186 failure(
17187 NodeFailureCode::UnknownContextPack,
17188 "context pack is not present",
17189 )
17190 })?;
17191 Ok(NodeResponse::DurableContextPackResolved { context })
17192 }
17193 NodeRequest::ReadContextPack { digest } => {
17194 let (id, byte_len, bytes) = shared
17199 .context_catalog
17200 .read()
17201 .unwrap_or_else(|poisoned| poisoned.into_inner())
17202 .get_by_digest(&digest)
17203 .map(|pack| {
17204 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17205 })
17206 .ok_or_else(|| {
17207 failure(
17208 NodeFailureCode::UnknownContextPack,
17209 &format!("context pack {digest} not on this node"),
17210 )
17211 })?;
17212 tracing::debug!(
17213 digest = digest.as_str(),
17214 byte_len,
17215 "read context pack bytes",
17216 );
17217 Ok(NodeResponse::ContextPackBytesRead {
17218 pack: ContextPackBytesRead {
17219 digest,
17220 id,
17221 byte_len,
17222 bytes,
17223 },
17224 })
17225 }
17226 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17227 let provider = controlled_session(shared, connection_id, role, &session)?;
17228 if let Some(prompt) = initial_prompt.as_deref() {
17229 validate_node_text("resume initial prompt", prompt)?;
17230 }
17231 let runtime_requirement = if initial_prompt.is_some() {
17232 ProviderRuntimeRequirement::ResumeWithPrompt
17233 } else {
17234 ProviderRuntimeRequirement::Resume
17235 };
17236 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17237 let runtime_policy = shared
17238 .admit_provider_runtime(&provider, runtime_requirement)
17239 .await?;
17240 let working_directory = shared.bound_workspace_root(&session)?;
17241 let request = ResumeLaunchRequest {
17242 working_directory,
17243 terminal_size,
17244 initial_prompt,
17245 };
17246 request
17247 .validate()
17248 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17249 let command = shared.prepare_command(ControlCommand::Resume {
17250 instance_id: session.session.instance_id,
17251 target: ResumeTarget::CurrentProvider,
17252 runtime_policy,
17253 request,
17254 });
17255 shared.arm_resume(&session, command.id, runtime_policy)?;
17256 let dispatch = shared.dispatch_envelope(command);
17257 if let Err(error) = dispatch {
17258 shared.clear_armed_resume(&session);
17259 return Err(error);
17260 }
17261 Ok(NodeResponse::Accepted)
17262 }
17263 NodeRequest::RenameSessionRecord {
17264 record_id,
17265 display_name,
17266 } => {
17267 shared.require_controller(connection_id, role)?;
17268 let record = shared.rename_session_record(&record_id, display_name)?;
17269 Ok(NodeResponse::SessionRecordUpdated { record })
17270 }
17271 NodeRequest::SetSessionTask {
17272 record_id,
17273 expected_revision,
17274 target,
17275 } => {
17276 shared.require_controller(connection_id, role)?;
17277 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17278 Ok(NodeResponse::SessionRecordUpdated { record })
17279 }
17280 NodeRequest::IndexProviderSession {
17281 workspace_id,
17282 provider,
17283 identity,
17284 display_name,
17285 } => {
17286 shared.require_controller(connection_id, role)?;
17287 let record = shared.index_provider_session(
17288 workspace_id,
17289 provider,
17290 identity,
17291 display_name,
17292 )?;
17293 Ok(NodeResponse::ProviderSessionIndexed { record })
17294 }
17295 NodeRequest::ResumeSessionRecord {
17296 record_id,
17297 terminal_size,
17298 initial_prompt,
17299 } => {
17300 shared.require_controller(connection_id, role)?;
17301 if let Some(prompt) = initial_prompt.as_deref() {
17302 validate_node_text("managed resume initial prompt", prompt)?;
17303 }
17304 let (record, session) = shared
17305 .resume_session_record(
17306 &record_id,
17307 terminal_size,
17308 initial_prompt,
17309 )
17310 .await?;
17311 Ok(NodeResponse::SessionRecordResumed { record, session })
17312 }
17313 NodeRequest::ForgetSessionRecord { record_id } => {
17314 shared.require_controller(connection_id, role)?;
17315 shared.forget_session_record(&record_id).await?;
17316 Ok(NodeResponse::SessionRecordForgotten { record_id })
17317 }
17318 NodeRequest::Prompt { session, text } => {
17319 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17320 validate_node_text("prompt", &text)?;
17321 shared.require_session_runtime_policy(
17322 &session,
17323 ProviderRuntimeRequirement::SemanticPrompt,
17324 )?;
17325 let framing = prompt_framing(&agent_id);
17326 shared
17327 .dispatch_input_bounded(
17328 &session,
17329 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17330 )
17331 .await?;
17332 Ok(NodeResponse::Accepted)
17333 }
17334 NodeRequest::Paste { session, text } => {
17335 controlled_session(shared, connection_id, role, &session)?;
17336 validate_node_text("paste", &text)?;
17337 shared.require_session_runtime_policy(
17338 &session,
17339 ProviderRuntimeRequirement::SemanticPrompt,
17340 )?;
17341 shared
17342 .dispatch_input_bounded(
17343 &session,
17344 InputAction::InsertDraft(PromptPayload {
17345 text,
17346 framing: PromptFraming::BracketedPaste,
17347 }),
17348 )
17349 .await?;
17350 Ok(NodeResponse::Accepted)
17351 }
17352 NodeRequest::Input { session, text } => {
17353 controlled_session(shared, connection_id, role, &session)?;
17354 validate_node_text("terminal input", &text)?;
17355 shared
17356 .dispatch_input_bounded(
17357 &session,
17358 InputAction::TerminalText(TerminalText { text }),
17359 )
17360 .await?;
17361 Ok(NodeResponse::Accepted)
17362 }
17363 NodeRequest::TerminalBytes { session, bytes } => {
17364 controlled_session(shared, connection_id, role, &session)?;
17365 shared.sessionize_spawn(session.session.instance_id);
17366 let action = terminal_bytes_action(bytes)?;
17367 shared
17368 .dispatch_input_bounded(&session, action)
17369 .await?;
17370 Ok(NodeResponse::Accepted)
17371 }
17372 NodeRequest::TerminalControl { session, control } => {
17373 controlled_session(shared, connection_id, role, &session)?;
17374 shared
17375 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17376 .await?;
17377 Ok(NodeResponse::Accepted)
17378 }
17379 NodeRequest::Resize { session, size } => {
17380 controlled_session(shared, connection_id, role, &session)?;
17381 shared.sessionize_spawn(session.session.instance_id);
17382 if !size.is_valid() {
17383 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17384 }
17385 shared.dispatch_resize_bounded(&session, size).await?;
17386 Ok(NodeResponse::Accepted)
17387 }
17388 NodeRequest::Interrupt { session } => {
17389 controlled_session(shared, connection_id, role, &session)?;
17390 shared
17391 .dispatch_input_bounded(
17392 &session,
17393 InputAction::TerminalControl(TerminalControl::Interrupt),
17394 )
17395 .await?;
17396 Ok(NodeResponse::Accepted)
17397 }
17398 NodeRequest::Stop { session, force } => {
17399 controlled_session(shared, connection_id, role, &session)?;
17400 shared.sessionize_spawn(session.session.instance_id);
17401 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17402 shared.retire_session_grok_leader(session.session.instance_id);
17403 Ok(NodeResponse::Accepted)
17404 }
17405 NodeRequest::Remove { session } => {
17406 controlled_session(shared, connection_id, role, &session)?;
17407 shared.remove_session(&session).await?;
17408 Ok(NodeResponse::Accepted)
17409 }
17410 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17411 shared.require_controller(connection_id, role)?;
17412 let snapshot = shared.require_acp_session(&session)?;
17413 let interaction = snapshot
17414 .provider
17415 .interactions
17416 .iter()
17417 .find(|interaction| {
17418 interaction_correlation(
17419 session.session.instance_id,
17420 session.session.generation,
17421 interaction.id.0,
17422 ) == correlation_id
17423 })
17424 .ok_or_else(|| failure(
17425 NodeFailureCode::InvalidRequest,
17426 "unknown interaction correlation",
17427 ))?;
17428 if interaction.status != ProviderInteractionStatus::Pending {
17429 return Err(failure(
17430 NodeFailureCode::BackendOperationFailed,
17431 "interaction has already been resolved",
17432 ));
17433 }
17434 response
17435 .validate_for(interaction.interaction_kind)
17436 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17437 let interaction_id = interaction.id;
17438 let response_kind = response.kind();
17439 let response_outcome = response.outcome();
17440 shared.dispatch(ControlCommand::ResolveInteraction {
17441 instance_id: session.session.instance_id,
17442 generation: session.session.generation,
17443 interaction_id,
17444 response,
17445 })?;
17446 tracing::info!(
17452 node_id = %shared.node_id,
17453 session = ?session.session,
17454 correlation_id = %correlation_id,
17455 interaction_id = interaction_id.0,
17456 response_kind = ?response_kind,
17457 outcome = ?response_outcome,
17458 "interaction resolved",
17459 );
17460 Ok(NodeResponse::Accepted)
17461 }
17462 NodeRequest::SetSessionMode { session, mode_id } => {
17463 shared.require_controller(connection_id, role)?;
17464 shared.require_acp_session(&session)?;
17465 shared.dispatch(ControlCommand::SetSessionMode {
17466 instance_id: session.session.instance_id,
17467 mode_id,
17468 })?;
17469 Ok(NodeResponse::Accepted)
17470 }
17471 NodeRequest::SetSessionConfigOption {
17472 session,
17473 option_id,
17474 value_json,
17475 } => {
17476 shared.require_controller(connection_id, role)?;
17477 shared.require_acp_session(&session)?;
17478 shared.dispatch(ControlCommand::SetSessionConfigOption {
17479 instance_id: session.session.instance_id,
17480 option_id,
17481 value_json,
17482 })?;
17483 Ok(NodeResponse::Accepted)
17484 }
17485 NodeRequest::SetSessionModel { session, model_id } => {
17486 shared.require_controller(connection_id, role)?;
17487 shared.require_acp_session(&session)?;
17488 shared.dispatch(ControlCommand::SetSessionModel {
17489 instance_id: session.session.instance_id,
17490 model_id,
17491 })?;
17492 Ok(NodeResponse::Accepted)
17493 }
17494 NodeRequest::Shutdown => {
17495 shared.require_controller(connection_id, role)?;
17496 shared.begin_shutdown_locked().await?;
17497 Ok(NodeResponse::ShuttingDown)
17498 }
17499 }
17500}
17501
17502fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17503 shared.require_controller(connection_id, role)?;
17504 shared.validate_address(session)
17505}
17506
17507fn select_history_candidate(
17513 candidates: &[HistoryCandidateSummary],
17514 provider_session_id: Option<&str>,
17515) -> Result<String, NodeFailure> {
17516 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17517 Some(id) => candidate.session_id_hint == id,
17518 None => true,
17519 });
17520 let candidate = matches.next().ok_or_else(|| {
17521 failure(
17522 NodeFailureCode::SessionRecordConflict,
17523 if provider_session_id.is_some() {
17524 "no history candidate matches the managed provider session"
17525 } else {
17526 "no history candidate exists for the managed session"
17527 },
17528 )
17529 })?;
17530 if matches.next().is_some() {
17531 return Err(failure(
17532 NodeFailureCode::SessionRecordConflict,
17533 if provider_session_id.is_some() {
17534 "history identity is ambiguous for the managed provider session"
17535 } else {
17536 "history is ambiguous without a verified provider session identity"
17537 },
17538 ));
17539 }
17540 Ok(candidate.id.clone())
17541}
17542
17543fn resolved_spawn_profile_summary(
17544 profile: &SpawnProfileDefaults,
17545 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17546) -> Option<SpawnProfileSummary> {
17547 let environment_profile = match profile.environment_profile_id.as_ref() {
17548 None => None,
17549 Some(profile_id) => {
17550 let binding = environment_profiles.get(profile_id)?;
17551 if binding.provider != profile.provider
17552 || binding.native_profile_id(profile.mode).is_none()
17553 {
17554 return None;
17555 }
17556 Some(ResolvedEnvironmentProfileReceipt {
17557 profile_id: binding.id.clone(),
17558 profile_revision: binding.revision.clone(),
17559 network_allowlist: None,
17560 browser_profile_id: None,
17561 })
17562 }
17563 };
17564 Some(SpawnProfileSummary {
17565 id: profile.profile_id.clone(),
17566 revision: profile.revision.clone(),
17567 environment_profile,
17568 })
17569}
17570
17571fn session_record_export_target_matches(
17590 expected: &ManagedSessionRecord,
17591 current: &ManagedSessionRecord,
17592 session: &SessionAddress,
17593 allow_clean_detachment: bool,
17594) -> bool {
17595 if current == expected && current.active_session.as_ref() == Some(session) {
17596 return current.state == ManagedSessionState::Live;
17597 }
17598 allow_clean_detachment
17599 && current.state == ManagedSessionState::Dormant
17600 && current.active_session.is_none()
17601 && current.record_id == expected.record_id
17602 && current.display_name == expected.display_name
17603 && current.provider == expected.provider
17604 && current.mode == expected.mode
17605 && current.workspace_id == expected.workspace_id
17606 && current.canonical_root == expected.canonical_root
17607 && current.provider_session == expected.provider_session
17608 && current.environment_profile == expected.environment_profile
17609 && current.bundle == expected.bundle
17610 && current.context_id == expected.context_id
17611 && current.context == expected.context
17612 && current.exported_context == expected.exported_context
17613 && current.task_binding == expected.task_binding
17614 && current.created_at_unix_ms == expected.created_at_unix_ms
17615 && current.last_error == expected.last_error
17616}
17617
17618fn session_record_context_export_binding_is_exact(
17619 expected: &ManagedSessionRecord,
17620 current: &ManagedSessionRecord,
17621 identity: Option<&ProviderSessionIdentity>,
17622 session: &SessionAddress,
17623 binding: &SessionBinding,
17624 provider: &AgentId,
17625 current_root: &str,
17626 allow_clean_detachment: bool,
17627) -> bool {
17628 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17629 && current.provider_session.as_ref() == identity
17630 && current.workspace_id == session.workspace_id
17631 && provider == ¤t.provider
17632 && binding.workspace_id == session.workspace_id
17633 && binding.generation == session.session.generation
17634 && binding.record_id.as_ref() == Some(¤t.record_id)
17635 && platform::roots_equal(
17636 current_root,
17637 windows_path_text(¤t.canonical_root),
17638 )
17639}
17640
17641fn session_record_context_export_source_is_usable(
17642 record: &ManagedSessionRecord,
17643 status: &SessionStatus,
17644 allow_clean_detachment: bool,
17645) -> bool {
17646 (record.state == ManagedSessionState::Live
17647 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17648 && context_pack_source_status_is_usable(status)
17649}
17650
17651fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17652 PromptFraming::BracketedPaste
17653}
17654
17655fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17656 if text.len() > MAX_NODE_TEXT_BYTES {
17657 return Err(failure(
17658 NodeFailureCode::InvalidRequest,
17659 &format!(
17660 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17661 text.len(),
17662 ),
17663 ));
17664 }
17665 Ok(())
17666}
17667
17668fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17669 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17670 return Err(failure(
17671 NodeFailureCode::InvalidRequest,
17672 &format!(
17673 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17674 bytes.len(),
17675 ),
17676 ));
17677 }
17678 Ok(())
17679}
17680
17681fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17682 validate_terminal_bytes(&bytes)?;
17683 Ok(InputAction::TerminalBytes(bytes))
17684}
17685
17686fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17687 if root.is_empty()
17688 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17689 || root.chars().any(char::is_control)
17690 {
17691 return Err(failure(
17692 NodeFailureCode::InvalidWorkspaceRoot,
17693 &format!(
17694 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17695 ),
17696 ));
17697 }
17698 Ok(())
17699}
17700
17701fn canonical_standalone_workspace_candidate(
17702 workspace_id: &WorkspaceId,
17703 root: &str,
17704) -> Result<String, NodeServerError> {
17705 let path = Path::new(root);
17706 match std::fs::symlink_metadata(path) {
17707 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17708 .map(|workspace| workspace.canonical_root),
17709 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17710 if !path.is_absolute() {
17711 return Err(NodeServerError::InvalidWorkspaceRoot {
17712 workspace_id: workspace_id.clone(),
17713 path: root.to_owned(),
17714 message: "path is not absolute".to_owned(),
17715 });
17716 }
17717 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17718 workspace_id: workspace_id.clone(),
17719 path: root.to_owned(),
17720 message: "path has no standalone directory name".to_owned(),
17721 })?;
17722 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17723 workspace_id: workspace_id.clone(),
17724 path: root.to_owned(),
17725 message: "path has no parent directory".to_owned(),
17726 })?;
17727 let canonical = std::fs::canonicalize(parent)
17728 .map(|parent| parent.join(name))
17729 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17730 workspace_id: workspace_id.clone(),
17731 path: root.to_owned(),
17732 message: error.to_string(),
17733 })?;
17734 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17735 NodeServerError::InvalidWorkspaceRoot {
17736 workspace_id: workspace_id.clone(),
17737 path: path.to_string_lossy().into_owned(),
17738 message: "canonical path is not valid Unicode".to_owned(),
17739 }
17740 })?;
17741 let canonical_root = platform::normalize_canonical_root(canonical_root);
17742 validate_workspace_root(workspace_id, &canonical_root)?;
17743 Ok(canonical_root)
17744 }
17745 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17746 workspace_id: workspace_id.clone(),
17747 path: root.to_owned(),
17748 message: error.to_string(),
17749 }),
17750 }
17751}
17752
17753fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17754 let Some(revision) = revision else {
17755 return Ok(());
17756 };
17757 if revision.is_empty()
17758 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17759 || revision.chars().any(char::is_control)
17760 {
17761 return Err(failure(
17762 NodeFailureCode::InvalidRequest,
17763 &format!(
17764 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17765 ),
17766 ));
17767 }
17768 Ok(())
17769}
17770
17771fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17772 let code = match error.kind {
17773 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17774 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17775 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17776 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17777 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17778 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17779 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17780 };
17781 failure(code, &error.message)
17782}
17783
17784fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17785 let code = match error.kind {
17786 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17787 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17788 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17789 NodeFailureCode::BackendOperationFailed
17790 }
17791 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17792 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17793 }
17794 };
17795 failure(code, error.message)
17796}
17797
17798fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17799 let (code, message) = match error.kind {
17800 GitWorktreeErrorKind::Invalid => (
17801 NodeFailureCode::InvalidRequest,
17802 "managed Git input is invalid",
17803 ),
17804 GitWorktreeErrorKind::NotRepository => (
17805 NodeFailureCode::NotGitRepository,
17806 "managed source is not a Git repository",
17807 ),
17808 GitWorktreeErrorKind::Conflict => (
17809 NodeFailureCode::WorktreeConflict,
17810 "managed Git identity conflicts with existing repository state",
17811 ),
17812 GitWorktreeErrorKind::Protected => (
17813 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17814 "managed worktree ownership validation failed",
17815 ),
17816 GitWorktreeErrorKind::Dirty => (
17817 NodeFailureCode::WorktreeDirty,
17818 "managed worktree contains uncommitted changes",
17819 ),
17820 GitWorktreeErrorKind::Locked => (
17821 NodeFailureCode::WorktreeLocked,
17822 "managed worktree is locked",
17823 ),
17824 GitWorktreeErrorKind::Failed => (
17825 NodeFailureCode::BackendOperationFailed,
17826 "managed Git operation failed",
17827 ),
17828 };
17829 NodeFailure { code, message: message.to_owned() }
17830}
17831
17832fn managed_spawn_receipt(
17833 resolved: &ResolvedSpawnSpec,
17834 incarnation_id: NodeIncarnationId,
17835 session: SessionAddress,
17836 lease: ManagedWorktreeLeaseSnapshot,
17837 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17838 bundle: Option<ResolvedBundleReceipt>,
17839 context: Option<ResolvedContextPackReceipt>,
17840) -> ManagedWorktreeSpawnReceipt {
17841 let mut managed = resolved.clone();
17842 managed.target.worktree_id = Some(lease.workspace_id.clone());
17843 ManagedWorktreeSpawnReceipt {
17844 spawn: managed.receipt_with_materialization(
17845 incarnation_id,
17846 session,
17847 environment_profile,
17848 bundle,
17849 context,
17850 ),
17851 lease,
17852 }
17853}
17854
17855fn validate_selected_worktree(
17856 source_root: &str,
17857 selected_root: &str,
17858 worktrees: &[NativeGitWorktreeSnapshot],
17859) -> Result<(), NodeFailure> {
17860 if !worktrees
17861 .iter()
17862 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17863 {
17864 return Err(failure(
17865 NodeFailureCode::WorktreeProtected,
17866 "source workspace is not an authoritative non-bare Git worktree root",
17867 ));
17868 }
17869 if !worktrees.iter().any(|worktree| {
17870 worktree_paths_equal(&worktree.path, selected_root)
17871 && !worktree.is_main
17872 && !worktree.is_bare
17873 }) {
17874 return Err(failure(
17875 NodeFailureCode::WorktreeProtected,
17876 "selected workspace is not an eligible Git worktree in the source repository",
17877 ));
17878 }
17879 Ok(())
17880}
17881
17882fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17883 let code = match error.kind() {
17884 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17885 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17886 NodeFailureCode::RepositoryPathUnsafe
17887 }
17888 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17889 WorkspaceFileReadErrorKind::NotRegularFile => {
17890 NodeFailureCode::RepositoryFileNotRegular
17891 }
17892 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17893 NodeFailureCode::RepositoryFileReadFailed
17894 }
17895 WorkspaceFileReadErrorKind::RevisionConflict => {
17896 NodeFailureCode::RepositoryFileRevisionConflict
17897 }
17898 WorkspaceFileReadErrorKind::AlreadyExists
17899 | WorkspaceFileReadErrorKind::ParentNotFound
17900 | WorkspaceFileReadErrorKind::ParentNotDirectory
17901 | WorkspaceFileReadErrorKind::Canceled => {
17902 NodeFailureCode::RepositoryFileReadFailed
17903 }
17904 };
17905 failure(code, "repository file read failed")
17906}
17907
17908fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17909 let code = match error.kind() {
17910 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17911 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17912 NodeFailureCode::RepositoryPathUnsafe
17913 }
17914 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17915 WorkspaceFileReadErrorKind::NotRegularFile => {
17916 NodeFailureCode::RepositoryFileNotRegular
17917 }
17918 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17919 NodeFailureCode::RepositoryFileWriteFailed
17920 }
17921 WorkspaceFileReadErrorKind::RevisionConflict => {
17922 NodeFailureCode::RepositoryFileRevisionConflict
17923 }
17924 WorkspaceFileReadErrorKind::AlreadyExists
17925 | WorkspaceFileReadErrorKind::ParentNotFound
17926 | WorkspaceFileReadErrorKind::ParentNotDirectory
17927 | WorkspaceFileReadErrorKind::Canceled => {
17928 NodeFailureCode::RepositoryFileWriteFailed
17929 }
17930 };
17931 failure(code, "repository file write failed")
17932}
17933
17934async fn settle_workspace_entry_create<T>(
17935 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17936 commit_state: Arc<AtomicU8>,
17937 deadline: Duration,
17938 timeout_message: &'static str,
17939 task_failure_message: &'static str,
17940) -> Result<T, NodeFailure> {
17941 let joined = match timeout(deadline, &mut task).await {
17942 Ok(joined) => joined,
17943 Err(_) => {
17944 if commit_state
17945 .compare_exchange(
17946 WORKSPACE_ENTRY_CREATE_PENDING,
17947 WORKSPACE_ENTRY_CREATE_CANCELED,
17948 Ordering::AcqRel,
17949 Ordering::Acquire,
17950 )
17951 .is_ok()
17952 {
17953 return Err(failure(
17954 NodeFailureCode::RepositoryEntryCreateTimedOut,
17955 timeout_message,
17956 ));
17957 }
17958 debug_assert_eq!(
17959 commit_state.load(Ordering::Acquire),
17960 WORKSPACE_ENTRY_CREATE_COMMITTING,
17961 );
17962 task.await
17963 }
17964 };
17965 joined
17966 .map_err(|_| {
17967 failure(
17968 NodeFailureCode::RepositoryEntryCreateFailed,
17969 task_failure_message,
17970 )
17971 })?
17972 .map_err(workspace_entry_create_failure)
17973}
17974
17975fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17976 let code = match error.kind() {
17977 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17978 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17979 NodeFailureCode::RepositoryPathUnsafe
17980 }
17981 WorkspaceFileReadErrorKind::AlreadyExists => {
17982 NodeFailureCode::RepositoryEntryAlreadyExists
17983 }
17984 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17985 NodeFailureCode::RepositoryParentNotFound
17986 }
17987 WorkspaceFileReadErrorKind::ParentNotDirectory
17988 | WorkspaceFileReadErrorKind::NotRegularFile => {
17989 NodeFailureCode::RepositoryParentNotDirectory
17990 }
17991 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17992 NodeFailureCode::RepositoryEntryCreateFailed
17993 }
17994 WorkspaceFileReadErrorKind::RevisionConflict => {
17995 NodeFailureCode::RepositoryEntryCreateFailed
17996 }
17997 WorkspaceFileReadErrorKind::Canceled => {
17998 NodeFailureCode::RepositoryEntryCreateTimedOut
17999 }
18000 };
18001 failure(code, "repository entry create failed")
18002}
18003
18004fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
18005 let value = digest(&SHA256, bytes)
18006 .as_ref()
18007 .iter()
18008 .map(|byte| format!("{byte:02x}"))
18009 .collect();
18010 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
18011}
18012
18013fn managed_spawn_request_digest_v2(
18014 request: &ManagedWorktreeSpawnRequestV2,
18015) -> Result<String, NodeFailure> {
18016 let encoded = serde_json::to_vec(request).map_err(|_| {
18017 failure(
18018 NodeFailureCode::InvalidRequest,
18019 "managed worktree V2 request could not be canonicalized",
18020 )
18021 })?;
18022 let mut value = String::with_capacity(71);
18023 value.push_str("sha256:");
18024 for byte in digest(&SHA256, &encoded).as_ref() {
18025 use std::fmt::Write as _;
18026 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18027 }
18028 Ok(value)
18029}
18030
18031fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18032 let code = match error {
18033 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18034 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18035 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18036 NativeSessionPreviewError::UnsupportedProvider
18037 | NativeSessionPreviewError::PreviewUnavailable
18038 | NativeSessionPreviewError::SessionNotFound
18039 | NativeSessionPreviewError::AmbiguousSession => {
18040 NodeFailureCode::BackendOperationFailed
18041 }
18042 };
18043 failure(code, "native session preview failed")
18044}
18045
18046fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18047 let code = match error.kind() {
18048 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18049 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18050 };
18051 failure(code, "host directory browse failed")
18052}
18053
18054fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18055 let code = match error {
18056 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18057 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18058 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18059 NodeFailureCode::DeliveryStageConflict
18060 }
18061 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18062 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18063 NodeFailureCode::DeliveryChunkOutOfOrder
18064 }
18065 DeliveryStoreError::BlobDigestMismatch => {
18066 NodeFailureCode::DeliveryBlobDigestMismatch
18067 }
18068 DeliveryStoreError::BundleDigestMismatch => {
18069 NodeFailureCode::DeliveryBundleDigestMismatch
18070 }
18071 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18072 DeliveryStoreError::Unavailable
18073 | DeliveryStoreError::Corrupt
18074 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18075 };
18076 failure(code, "delivery operation failed")
18077}
18078
18079fn validate_workspace_root(
18080 workspace_id: &WorkspaceId,
18081 canonical_root: &str,
18082) -> Result<(), NodeServerError> {
18083 #[cfg(windows)]
18084 if canonical_root.starts_with(r"\\") {
18085 return Err(NodeServerError::InvalidWorkspaceRoot {
18086 workspace_id: workspace_id.clone(),
18087 path: canonical_root.to_owned(),
18088 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18089 });
18090 }
18091 if !platform::workspace_root_supported(canonical_root)
18092 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18093 {
18094 return Err(NodeServerError::InvalidWorkspaceRoot {
18095 workspace_id: workspace_id.clone(),
18096 path: canonical_root.to_owned(),
18097 message: "canonical path must be absolute, bounded, and free of control characters"
18098 .to_owned(),
18099 });
18100 }
18101 Ok(())
18102}
18103
18104fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18105 NodeFailure {
18106 code,
18107 message: node_failure_category(code).to_owned(),
18108 }
18109}
18110
18111fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18112 let code = match error {
18113 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18114 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18115 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18116 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18117 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18118 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18119 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18120 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18121 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18122 };
18123 failure(code, "harness MCP proxy operation failed")
18124}
18125
18126static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18130
18131fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18138 let trace = launch.trace.as_ref()?;
18139 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18140 if trace_dir.is_empty() {
18141 return None;
18142 }
18143 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18144 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18145 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18146}
18147
18148fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18149 let remaining = deadline.saturating_duration_since(Instant::now());
18150 if remaining.is_zero() {
18151 Err(failure(
18152 NodeFailureCode::SpawnDeadlineExceeded,
18153 "spawn deadline elapsed",
18154 ))
18155 } else {
18156 Ok(remaining)
18157 }
18158}
18159
18160fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18161 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18162 match deadline {
18163 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18164 None => Ok(bounded),
18165 }
18166}
18167
18168fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18169 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18170 failure(
18171 NodeFailureCode::SpawnDeadlineExceeded,
18172 "spawn dispatch exceeded the spawn deadline",
18173 )
18174 } else {
18175 error
18176 }
18177}
18178
18179fn spawn_runtime_capabilities(
18180 capabilities: &SpawnRequiredCapabilities,
18181) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18182 capabilities
18183 .iter()
18184 .map(|capability| match capability.as_str() {
18185 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18186 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18187 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18188 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18189 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18190 }
18191 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18192 _ => Err(failure(
18193 NodeFailureCode::UnsupportedSpawnCapability,
18194 "spawn required capability is unknown",
18195 )),
18196 })
18197 .collect()
18198}
18199
18200fn persistence_failure(_error: io::Error) -> NodeFailure {
18201 NodeFailure {
18202 code: NodeFailureCode::BackendOperationFailed,
18203 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18204 }
18205}
18206
18207fn node_failure_category(code: NodeFailureCode) -> &'static str {
18208 match code {
18209 NodeFailureCode::InvalidRequest => "invalid-request",
18210 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18211 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18212 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18213 NodeFailureCode::ReservationConflict => "reservation-conflict",
18214 NodeFailureCode::ReservationExpired => "reservation-expired",
18215 NodeFailureCode::BindingMismatch => "binding-mismatch",
18216 NodeFailureCode::NotActivated => "not-activated",
18217 NodeFailureCode::CallNotFound => "call-not-found",
18218 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18219 NodeFailureCode::ResponseTooLarge => "response-too-large",
18220 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18221 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18222 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18223 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18224 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18225 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18226 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18227 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18228 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18229 NodeFailureCode::Unauthorized => "unauthorized",
18230 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18231 NodeFailureCode::ControllerBusy => "controller-busy",
18232 NodeFailureCode::ControllerRequired => "controller-required",
18233 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18234 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18235 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18236 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18237 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18238 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18239 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18240 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18241 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18242 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18243 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18244 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18245 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18246 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18247 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18248 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18249 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18250 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18251 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18252 NodeFailureCode::GitReadFailed => "git-read-failed",
18253 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18254 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18255 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18256 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18257 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18258 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18259 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18260 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18261 NodeFailureCode::TurnInFlight => "turn-in-flight",
18262 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18263 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18264 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18265 "unsupported-network-allowlist-mapping"
18266 }
18267 NodeFailureCode::BrowserStationProbeUnavailable => {
18268 "browser-station-probe-unavailable"
18269 }
18270 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18271 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18272 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18273 "environment-profile-binding-mismatch"
18274 }
18275 NodeFailureCode::UnknownBundle => "unknown-bundle",
18276 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18277 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18278 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18279 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18280 NodeFailureCode::ContextPackMaterializationFailed => {
18281 "context-pack-materialization-failed"
18282 }
18283 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18284 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18285 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18286 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18287 NodeFailureCode::LastWorkspace => "last-workspace",
18288 NodeFailureCode::NotGitRepository => "not-git-repository",
18289 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18290 NodeFailureCode::WorktreeProtected => "worktree-protected",
18291 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18292 NodeFailureCode::WorktreeLocked => "worktree-locked",
18293 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18294 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18295 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18296 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18297 "managed-worktree-profile-revision-mismatch"
18298 }
18299 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18300 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18301 "standalone-workspace-recovery-required"
18302 }
18303 NodeFailureCode::UnknownSession => "unknown-session",
18304 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18305 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18306 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18307 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18308 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18309 NodeFailureCode::WorkspaceRegistrationRequired => {
18310 "workspace-registration-required"
18311 }
18312 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18313 NodeFailureCode::StaleGeneration => "stale-generation",
18314 NodeFailureCode::BackendBusy => "backend-busy",
18315 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18316 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18317 NodeFailureCode::ShuttingDown => "shutting-down",
18318 }
18319}
18320
18321fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18322 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18323}
18324
18325fn durable_state_load_error(error: io::Error) -> NodeServerError {
18326 let category = match session_registry::state_load_refusal(&error) {
18327 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18328 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18329 }
18330 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18331 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18332 }
18333 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18334 DURABLE_STATE_CONFLICT_ERROR
18335 }
18336 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18337 };
18338 durable_state_server_error(error, category)
18339}
18340
18341fn unix_time_ms() -> u64 {
18342 std::time::SystemTime::now()
18343 .duration_since(std::time::UNIX_EPOCH)
18344 .unwrap_or_default()
18345 .as_millis()
18346 .min(u64::MAX as u128) as u64
18347}
18348
18349fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18350 if record.provider_session.is_some() {
18351 ManagedSessionState::Dormant
18352 } else {
18353 ManagedSessionState::Unavailable
18354 }
18355}
18356
18357fn active_record_state(
18358 identity_admitted: bool,
18359 provider_session_is_known: bool,
18360) -> ManagedSessionState {
18361 if identity_admitted && !provider_session_is_known {
18362 ManagedSessionState::IdentityPending
18363 } else {
18364 ManagedSessionState::Live
18365 }
18366}
18367
18368fn managed_worktree_record_holders(
18369 records: &[ManagedSessionRecord],
18370) -> Vec<(SessionRecordId, WorkspaceId)> {
18371 records
18372 .iter()
18373 .filter(|record| record.provider_session.is_some())
18374 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18375 .collect()
18376}
18377
18378fn managed_spawn_record_ownership(
18379 binding: &SessionBinding,
18380 effective_runtime_policy: ProviderRuntimePolicy,
18381) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18382 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18383 if effective_runtime_policy.provider_session_identity {
18384 (binding.record_id.clone(), None)
18385 } else {
18386 (None, binding.record_id.clone())
18387 }
18388}
18389
18390#[derive(Debug, Error)]
18391pub enum NodeServerError {
18392 #[error("node endpoint must be a bounded absolute local transport path")]
18393 InvalidEndpoint,
18394 #[error("node access token must contain 1..=4096 bytes")]
18395 InvalidAccessToken,
18396 #[error("node HTTP observer must listen on a loopback address: {0}")]
18397 InvalidApiListen(std::net::SocketAddr),
18398 #[error("node browser bridge must listen on a loopback address: {0}")]
18399 InvalidBridgeListen(std::net::SocketAddr),
18400 #[error("node browser bridge token must contain 1..=4096 bytes")]
18401 InvalidBridgeToken,
18402 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18403 BridgeUnderlayRequiresBridgeListen,
18404 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18405 BridgeUnderlayRequiresBridgeToken,
18406 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18407 InvalidBridgeUnderlayToken,
18408 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18409 InvalidBridgeUnderlayKey,
18410 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18411 InvalidCallHome(std::net::SocketAddr),
18412 #[error("node could not reach its call-home relay: {0}")]
18413 CallHomeTransport(#[source] std::io::Error),
18414 #[error("node durable state path must be an absolute file path: {0}")]
18415 InvalidStatePath(String),
18416 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18417 InvalidHarnessMcpHelper,
18418 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18419 InvalidNetworkAllowlistCatalogPath(String),
18420 #[error("network allowlist catalog could not be read: {0}")]
18421 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18422 #[error("network allowlist catalog line {line}: {message}")]
18423 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18424 #[error("network allowlist catalog: {0}")]
18425 InvalidNetworkAllowlistCatalog(String),
18426 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18427 NetworkAllowlistCatalogCapacity { max: usize },
18428 #[error("the local state directory is unavailable; supply an explicit state path")]
18429 LocalStateDirectoryUnavailable,
18430 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18431 LocalRuntimeDirectoryUnavailable,
18432 #[error("node durable state failed: {0}")]
18433 DurableState(io::Error),
18434 #[error("node requires at least one configured workspace")]
18435 NoWorkspaces,
18436 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18437 InvalidWorkspaceRoot {
18438 workspace_id: WorkspaceId,
18439 path: String,
18440 message: String,
18441 },
18442 #[error("duplicate workspace ID: {0}")]
18443 DuplicateWorkspaceId(WorkspaceId),
18444 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18445 DuplicateWorkspaceRoot {
18446 first: WorkspaceId,
18447 second: WorkspaceId,
18448 root: String,
18449 },
18450 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18451 ManagedWorktreeProfileRequired(WorkspaceId),
18452 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18453 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18454 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18455 DuplicateManagedWorktreeProfile {
18456 workspace_id: WorkspaceId,
18457 profile_id: WorktreeProfileId,
18458 },
18459 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18460 ManagedWorktreeProfileCapacity {
18461 workspace_id: WorkspaceId,
18462 max: usize,
18463 },
18464 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18465 InvalidManagedWorktreeProfile {
18466 workspace_id: WorkspaceId,
18467 profile_id: WorktreeProfileId,
18468 message: String,
18469 },
18470 #[error("duplicate node environment profile: {0}")]
18471 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18472 #[error("node environment profile provider is unavailable: {0}")]
18473 EnvironmentProfileProviderUnavailable(AgentId),
18474 #[error("node environment profile capacity is {max}")]
18475 EnvironmentProfileCapacity { max: usize },
18476 #[error("node environment profiles reuse native profile ID '{0}'")]
18477 DuplicateNativeEnvironmentProfile(String),
18478 #[error("node native environment profile is invalid: {0}")]
18479 NativeEnvironmentProfile(String),
18480 #[error("node session-environment materialization root must be absolute")]
18481 InvalidSessionEnvironmentRoot,
18482 #[error("node session-environment materializer is unavailable")]
18483 SessionEnvironmentMaterializerRequired,
18484 #[error("node session-environment materializer failed to initialize")]
18485 SessionEnvironmentMaterializer,
18486 #[error("node bundle catalog is invalid: {0}")]
18487 BundleCatalog(String),
18488 #[error("node delivery store failed to initialize: {0}")]
18489 DeliveryStore(String),
18490 #[error("node context pack store failed to initialize")]
18491 ContextPackStore,
18492 #[error("active agent registry failed: {0}")]
18493 Registry(String),
18494 #[error("node provider contract manifest is invalid: {0}")]
18495 ProviderContractManifest(String),
18496 #[error("named pipe I/O failed: {0}")]
18497 Io(#[from] io::Error),
18498 #[error("node HTTP observer failed: {0}")]
18499 HttpApi(io::Error),
18500 #[error("node browser bridge failed: {0}")]
18501 BridgeApi(io::Error),
18502 #[error(transparent)]
18503 Frame(#[from] FrameError),
18504 #[error("node handshake failed: {0}")]
18505 Handshake(String),
18506 #[error("node authentication frame was not received before the bounded deadline")]
18507 AuthenticationTimedOut,
18508 #[error("node authentication primitive failed: {0}")]
18509 Authentication(String),
18510 #[error("node incarnation identity generation failed: {0}")]
18511 IncarnationIdentity(String),
18512 #[error("node pre-authentication limiter is closed")]
18513 PreauthClosed,
18514 #[error("node authenticated connection limit was reached")]
18515 AuthenticatedConnectionLimit,
18516 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18517 SignalStreamClosed,
18518 #[error("node stop-all dispatch failed: {0:?}")]
18519 ShutdownDispatch(NodeFailure),
18520 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18521 ShutdownTimedOut { active_native_sessions: usize },
18522}
18523
18524impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18525 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18526 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18527 match error {
18528 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18529 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18530 E::InvalidEntry { line, message } => {
18531 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18532 }
18533 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18534 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18535 }
18536 }
18537}
18538
18539#[cfg(test)]
18540mod agent_stream_blocked_tests {
18541 use super::*;
18542 use gate4agent_types::{
18543 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18544 ProviderSource,
18545 };
18546
18547 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18548 ProviderSource {
18549 family,
18550 binding: AdapterBinding::new(
18551 AdapterId::new(adapter).unwrap(),
18552 "fixture/v1",
18553 AdapterVerification::SyntheticFixture,
18554 )
18555 .unwrap(),
18556 }
18557 }
18558
18559 fn provider_control_event_at(
18560 family: AdapterFamily,
18561 adapter: &str,
18562 source_sequence: u64,
18563 provider_event: ProviderEvent,
18564 ) -> ControlEvent {
18565 ControlEvent {
18566 sequence: 41,
18567 command_id: None,
18568 instance_id: AgentInstanceId(7),
18569 generation: SessionGeneration(3),
18570 event: ControlEventKind::ProviderEvent {
18571 sequence: 8,
18572 source: provider_source(family, adapter),
18573 source_sequence,
18574 event: provider_event,
18575 },
18576 }
18577 }
18578
18579 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18580 provider_control_event_at(family, "fixture", 9, provider_event)
18581 }
18582
18583 fn denied_host_request(
18584 by: ProviderHostDecisionAuthority,
18585 reason: Option<String>,
18586 ) -> ControlEvent {
18587 provider_control_event_at(
18588 AdapterFamily::Acp,
18589 "claude-code",
18590 2,
18591 ProviderEvent::HostRequestObserved {
18592 method: "terminal/create".to_owned(),
18593 params_json: String::new(),
18594 decision: ProviderHostRequestDecision::Denied { by },
18595 outcome: ProviderHostRequestOutcome::Executed,
18596 reason,
18597 },
18598 )
18599 }
18600
18601 fn blocked(
18602 event: &ControlEvent,
18603 ) -> (
18604 Option<String>,
18605 String,
18606 BlockAuthorityV1,
18607 Option<String>,
18608 String,
18609 Option<String>,
18610 ) {
18611 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18612 chunk.validate().expect("minted chunk must validate");
18613 let AgentStreamChunkKindV1::Blocked {
18614 correlation_id,
18615 tool_class,
18616 authority,
18617 reason_kind,
18618 reason,
18619 help,
18620 } = chunk.kind
18621 else {
18622 panic!("expected AgentStreamChunkKindV1::Blocked");
18623 };
18624 (correlation_id, tool_class, authority, reason_kind, reason, help)
18625 }
18626
18627 #[test]
18631 fn the_enabled_provider_matrix_is_exact() {
18632 let registry = active_registry().unwrap();
18633 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18634 assert_eq!(
18635 providers
18636 .iter()
18637 .map(|contract| contract.provider.as_str())
18638 .collect::<Vec<_>>(),
18639 vec!["shell", "claude", "codex", "grok", "kimi"],
18641 );
18642 assert!(adapters.iter().all(|contract| {
18643 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18644 }));
18645 }
18646
18647 #[test]
18650 fn gate_denied_host_request_mints_a_blocked_chunk() {
18651 let gate_text =
18652 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18653 let event = denied_host_request(
18654 ProviderHostDecisionAuthority::Gate,
18655 Some(gate_text.to_owned()),
18656 );
18657 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18658 assert_eq!(chunk.source_sequence, 2);
18659 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18660 assert_eq!(authority, BlockAuthorityV1::HostGate);
18661 assert_eq!(reason, gate_text);
18662 assert_eq!(correlation_id, None);
18663 assert_eq!(reason_kind, None);
18664 assert_eq!(help, None);
18665 assert_eq!(tool_class, "Write");
18666 }
18667
18668 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.";
18669
18670 #[test]
18676 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18677 let event = provider_control_event(
18678 AdapterFamily::Acp,
18679 ProviderEvent::ToolCompleted {
18680 id: "toolu_9".to_owned(),
18681 output: CLASSIFIER_SAMPLE.to_owned(),
18682 is_error: true,
18683 duration_ms: None,
18684 agent_id: None,
18685 non_execution_kind: Some("permission-rule".to_owned()),
18686 },
18687 );
18688 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18689 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18690 assert_eq!(reason, "Blocked by classifier");
18691 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18692 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18693 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18694 }
18695
18696 #[test]
18701 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18702 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.";
18703 let event = provider_control_event(
18704 AdapterFamily::Acp,
18705 ProviderEvent::SessionEnded {
18706 result: "JSON-RPC error -32603: Internal error".to_owned(),
18707 cost_usd: None,
18708 is_error: true,
18709 stop_reason: Some(ProviderStopReason::ProviderError {
18710 code: -32603,
18711 message: message.to_owned(),
18712 vendor_code: Some("usageLimitExceeded".to_owned()),
18713 }),
18714 },
18715 );
18716 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18717 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18718 assert_eq!(reason, message);
18719 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18720 assert_eq!(tool_class, "Session");
18721 }
18722
18723 #[test]
18726 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18727 let event = provider_control_event(
18728 AdapterFamily::Acp,
18729 ProviderEvent::SessionEnded {
18730 result: "refusal".to_owned(),
18731 cost_usd: None,
18732 is_error: true,
18733 stop_reason: Some(ProviderStopReason::Refusal),
18734 },
18735 );
18736 let (_, _, authority, _, _, _) = blocked(&event);
18737 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18738 }
18739
18740 #[test]
18744 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18745 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18746 let event = provider_control_event(
18747 AdapterFamily::Acp,
18748 ProviderEvent::ToolCompleted {
18749 id: "toolu_1".to_owned(),
18750 output: "command exited with status 1".to_owned(),
18751 is_error: true,
18752 duration_ms: Some(12),
18753 agent_id: None,
18754 non_execution_kind,
18755 },
18756 );
18757 assert!(agent_stream_chunk(&event).is_none());
18758 }
18759 let narrative = provider_control_event(
18760 AdapterFamily::Acp,
18761 ProviderEvent::ToolCompleted {
18762 id: "toolu_2".to_owned(),
18763 output: "the harness blocked the write, approval needed".to_owned(),
18764 is_error: false,
18765 duration_ms: Some(3),
18766 agent_id: None,
18767 non_execution_kind: None,
18768 },
18769 );
18770 assert!(agent_stream_chunk(&narrative).is_none());
18771 }
18772
18773 #[test]
18777 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18778 for (decision, outcome) in [
18779 (
18780 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18781 ProviderHostRequestOutcome::Executed,
18782 ),
18783 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18784 (
18785 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18786 ProviderHostRequestOutcome::Failed {
18787 error: "terminal/create spawn failed: os error 3".to_owned(),
18788 },
18789 ),
18790 ] {
18791 let event = provider_control_event_at(
18792 AdapterFamily::Acp,
18793 "claude-code",
18794 3,
18795 ProviderEvent::HostRequestObserved {
18796 method: "fs/read_text_file".to_owned(),
18797 params_json: String::new(),
18798 decision: decision.clone(),
18799 outcome,
18800 reason: None,
18801 },
18802 );
18803 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18804 }
18805 }
18806
18807 #[test]
18811 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18812 for (by, expected_authority, expected_reason) in [
18813 (
18814 ProviderHostDecisionAuthority::Operator,
18815 BlockAuthorityV1::Operator,
18816 "declined by the operator",
18817 ),
18818 (
18819 ProviderHostDecisionAuthority::DeadlinePolicy,
18820 BlockAuthorityV1::HostDeadline,
18821 "denied by host policy after the operator deadline expired",
18822 ),
18823 ] {
18824 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18825 assert_eq!(authority, expected_authority);
18826 assert_eq!(reason, expected_reason);
18827 }
18828 }
18829
18830 #[test]
18834 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18835 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18836 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18837 ProviderHostDecisionAuthority::Policy,
18838 Some(oversized),
18839 ));
18840 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18841 }
18842}
18843
18844#[cfg(test)]
18845mod standalone_capability_platform_tests {
18846 use super::*;
18847
18848 #[test]
18849 fn standalone_workspace_capability_requires_windows_identity_guards() {
18850 let capabilities = baseline_capabilities().unwrap();
18851 let advertised = capabilities.iter().any(|capability| {
18852 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18853 });
18854 assert_eq!(advertised, cfg!(windows));
18855
18856 let request = NodeRequest::CreateStandaloneWorkspace {
18857 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18858 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18859 initial_branch: None,
18860 };
18861 assert_eq!(
18862 request_uses_unnegotiated_capability(&request, &capabilities),
18863 !cfg!(windows),
18864 );
18865 }
18866}
18867
18868
18869#[cfg(test)]
18870mod station_knobs_resolve_tests {
18871 use super::*;
18872 use gate4agent_catalog::EnvMutation;
18873 use gate4agent_runtime_native::{
18874 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18875 NativeLaunchProfileId,
18876 };
18877 use std::ffi::OsString;
18878 use std::sync::Arc;
18879
18880 struct EmptyEnvironmentResolver;
18881
18882 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18883 fn resolve_child_environment(
18884 &self,
18885 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18886 Ok(vec![EnvMutation {
18887 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18888 value: None,
18889 }])
18890 }
18891 }
18892
18893 fn agent(value: &str) -> AgentId {
18894 AgentId::new(value).unwrap()
18895 }
18896
18897 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18898 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18899 let profile_revision =
18900 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18901 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18902 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18903 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18904 provider: agent("claude"),
18905 mode: SessionMode::Pty,
18906 terminal_size: gate4agent_types::TerminalSize {
18907 rows: 24,
18908 columns: 80,
18909 },
18910 prompt: None,
18911 bundle_id: None,
18912 context_id: None,
18913 environment_profile_id: Some(profile_id.clone()),
18914 };
18915 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18916 let catalog = active_registry().unwrap();
18917 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18918 let control = runtime.native_launch_profile_control();
18919 let node_profile = NodeEnvironmentProfile::new(
18920 profile_id.clone(),
18921 profile_revision,
18922 agent("claude"),
18923 [NativeLaunchProfile::new(
18924 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18925 agent("claude"),
18926 TransportKind::Pty,
18927 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18928 Arc::new(EmptyEnvironmentResolver),
18929 )
18930 .unwrap()],
18931 )
18932 .unwrap();
18933 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18934 for native_profile in native_profiles {
18935 runtime.upsert_native_launch_profile(native_profile).unwrap();
18936 }
18937 let workspace = WorkspaceConfig::new(
18938 WorkspaceId::new("primary").unwrap(),
18939 std::env::current_dir().unwrap(),
18940 )
18941 .unwrap();
18942 let shared = NodeShared::new_with_incarnation(
18943 handle,
18944 "fixture-token".to_owned(),
18945 NodeId::new("node-station-knobs-test").unwrap(),
18946 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18947 vec![workspace],
18948 vec![agent("claude")],
18949 ProviderRuntimeStatuses::default(),
18950 None,
18951 Vec::new(),
18952 Vec::new(),
18953 spawn_profiles,
18954 Some(control),
18955 None,
18956 None,
18957 None,
18958 Vec::new(),
18959 Vec::new(),
18960 Vec::new(),
18961 Vec::new(),
18962 None,
18963 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18964 );
18965 shared
18966 .environment_profiles
18967 .write()
18968 .unwrap_or_else(|poisoned| poisoned.into_inner())
18969 .insert(binding.id.clone(), binding);
18970 (shared, spawn_defaults)
18971 }
18972
18973 #[test]
18976 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18977 let (shared, defaults) = station_knobs_fixture();
18978 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18979 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18980 let spec = crate::protocol::SpawnSpec {
18981 target: crate::protocol::SpawnTarget {
18982 node_id: shared.node_id.clone(),
18983 workspace_id: WorkspaceId::new("primary").unwrap(),
18984 worktree_id: None,
18985 },
18986 profile_id: defaults.profile_id.clone(),
18987 expected_profile_revision: defaults.revision.clone(),
18988 overrides: crate::protocol::SpawnOverrides {
18989 network_allowlist: Some(unknown),
18990 browser_profile_id: Some(browser),
18991 ..crate::protocol::SpawnOverrides::default()
18992 },
18993 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18994 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18995 "station-knobs-unknown-allowlist",
18996 )
18997 .unwrap(),
18998 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18999 };
19000 let resolved = spec.resolve(&defaults).unwrap();
19001 let failure = shared
19002 .resolve_environment_profile(&resolved)
19003 .expect_err("unknown allowlist must refuse");
19004 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
19005 }
19006
19007 #[test]
19013 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
19014 let (shared, defaults) = station_knobs_fixture();
19015 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19016 #[cfg(not(feature = "dig2-station-probe"))]
19017 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19018 shared
19019 .network_allowlist_catalog
19020 .write()
19021 .unwrap_or_else(|poisoned| poisoned.into_inner())
19022 .insert_id(allowlist.clone())
19023 .unwrap();
19024 let spec = crate::protocol::SpawnSpec {
19025 target: crate::protocol::SpawnTarget {
19026 node_id: shared.node_id.clone(),
19027 workspace_id: WorkspaceId::new("primary").unwrap(),
19028 worktree_id: None,
19029 },
19030 profile_id: defaults.profile_id.clone(),
19031 expected_profile_revision: defaults.revision.clone(),
19032 overrides: crate::protocol::SpawnOverrides {
19033 network_allowlist: Some(allowlist.clone()),
19034 #[cfg(not(feature = "dig2-station-probe"))]
19035 browser_profile_id: Some(browser.clone()),
19036 ..crate::protocol::SpawnOverrides::default()
19037 },
19038 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19039 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19040 .unwrap(),
19041 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19042 };
19043 let resolved = spec.resolve(&defaults).unwrap();
19044 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19045 #[cfg(not(feature = "dig2-station-probe"))]
19046 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19047 let receipt = shared
19048 .resolve_environment_profile(&resolved)
19049 .expect("resolve ok")
19050 .expect("env profile present");
19051 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19052 #[cfg(not(feature = "dig2-station-probe"))]
19053 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19054 #[cfg(feature = "dig2-station-probe")]
19055 assert_eq!(receipt.browser_profile_id, None);
19056 assert_eq!(receipt.profile_id.as_str(), "local-claude");
19057
19058 let omitted = crate::protocol::SpawnSpec {
19059 overrides: crate::protocol::SpawnOverrides::default(),
19060 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19061 .unwrap(),
19062 ..spec
19063 };
19064 let resolved_omit = omitted.resolve(&defaults).unwrap();
19065 let bare = shared
19066 .resolve_environment_profile(&resolved_omit)
19067 .expect("resolve ok")
19068 .expect("env profile present");
19069 assert_eq!(bare.network_allowlist, None);
19070 assert_eq!(bare.browser_profile_id, None);
19071 }
19072
19073 #[cfg(feature = "dig2-station-probe")]
19078 #[test]
19079 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19080 let (shared, defaults) = station_knobs_fixture();
19081 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19082 let spec = crate::protocol::SpawnSpec {
19083 target: crate::protocol::SpawnTarget {
19084 node_id: shared.node_id.clone(),
19085 workspace_id: WorkspaceId::new("primary").unwrap(),
19086 worktree_id: None,
19087 },
19088 profile_id: defaults.profile_id.clone(),
19089 expected_profile_revision: defaults.revision.clone(),
19090 overrides: crate::protocol::SpawnOverrides {
19091 browser_profile_id: Some(browser),
19092 ..crate::protocol::SpawnOverrides::default()
19093 },
19094 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19095 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19096 "station-knobs-probe-refuse",
19097 )
19098 .unwrap(),
19099 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19100 };
19101 let resolved = spec.resolve(&defaults).unwrap();
19102 let failure = shared
19103 .resolve_environment_profile(&resolved)
19104 .expect_err("browser_profile with failing probe must refuse");
19105 #[cfg(windows)]
19106 {
19107 assert!(
19108 matches!(
19109 failure.code,
19110 NodeFailureCode::BrowserStationUnreachable
19111 | NodeFailureCode::BrowserStationProbeUnavailable
19112 ),
19113 "unexpected code {:?}",
19114 failure.code
19115 );
19116 }
19117 #[cfg(not(windows))]
19118 {
19119 assert_eq!(
19120 failure.code,
19121 NodeFailureCode::BrowserStationProbeUnavailable
19122 );
19123 }
19124 }
19125
19126 #[cfg(feature = "dig2-station-probe")]
19128 #[test]
19129 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19130 let (shared, defaults) = station_knobs_fixture();
19131 let spec = crate::protocol::SpawnSpec {
19132 target: crate::protocol::SpawnTarget {
19133 node_id: shared.node_id.clone(),
19134 workspace_id: WorkspaceId::new("primary").unwrap(),
19135 worktree_id: None,
19136 },
19137 profile_id: defaults.profile_id.clone(),
19138 expected_profile_revision: defaults.revision.clone(),
19139 overrides: crate::protocol::SpawnOverrides::default(),
19140 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19141 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19142 "station-knobs-probe-omit",
19143 )
19144 .unwrap(),
19145 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19146 };
19147 let resolved = spec.resolve(&defaults).unwrap();
19148 let receipt = shared
19149 .resolve_environment_profile(&resolved)
19150 .expect("omit browser_profile must not probe")
19151 .expect("env profile present");
19152 assert_eq!(receipt.browser_profile_id, None);
19153 }
19154
19155 #[cfg(feature = "dig2-station-probe")]
19159 #[test]
19160 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19161 let (shared, _defaults) = station_knobs_fixture();
19162 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19163 let workspace_id = WorkspaceId::new("primary").unwrap();
19164 let address_a = SessionAddress {
19165 workspace_id: workspace_id.clone(),
19166 session: SessionKey {
19167 instance_id: AgentInstanceId(101),
19168 generation: SessionGeneration(1),
19169 },
19170 };
19171 let address_b = SessionAddress {
19172 workspace_id: workspace_id.clone(),
19173 session: SessionKey {
19174 instance_id: AgentInstanceId(102),
19175 generation: SessionGeneration(1),
19176 },
19177 };
19178 let guard_a = crate::dig2_station_lease::acquire_lease(
19179 &shared.browser_station_leases,
19180 profile.clone(),
19181 address_a.session.instance_id,
19182 1,
19183 )
19184 .expect("first lease");
19185 let busy = crate::dig2_station_lease::acquire_lease(
19186 &shared.browser_station_leases,
19187 profile.clone(),
19188 address_b.session.instance_id,
19189 2,
19190 )
19191 .expect_err("second holder must refuse");
19192 assert!(matches!(
19193 busy,
19194 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19195 ));
19196 shared.bind_session_with_policy(
19198 &address_a,
19199 ProviderRuntimePolicy::raw_pty(),
19200 None,
19201 );
19202 guard_a.retain();
19203 assert!(shared.remove_binding(&address_a).is_some());
19204 crate::dig2_station_lease::acquire_lease(
19205 &shared.browser_station_leases,
19206 profile,
19207 address_b.session.instance_id,
19208 3,
19209 )
19210 .expect("reacquire after clear")
19211 .retain();
19212 assert!(
19213 crate::dig2_station_lease::release_lease_for_holder(
19214 &shared.browser_station_leases,
19215 address_b.session.instance_id,
19216 )
19217 );
19218 }
19219
19220 #[cfg(not(feature = "dig2-station-probe"))]
19223 #[test]
19224 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19225 let (shared, defaults) = station_knobs_fixture();
19226 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19227 let spec = crate::protocol::SpawnSpec {
19228 target: crate::protocol::SpawnTarget {
19229 node_id: shared.node_id.clone(),
19230 workspace_id: WorkspaceId::new("primary").unwrap(),
19231 worktree_id: None,
19232 },
19233 profile_id: defaults.profile_id.clone(),
19234 expected_profile_revision: defaults.revision.clone(),
19235 overrides: crate::protocol::SpawnOverrides {
19236 browser_profile_id: Some(browser.clone()),
19237 ..crate::protocol::SpawnOverrides::default()
19238 },
19239 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19240 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19241 .unwrap(),
19242 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19243 };
19244 let resolved = spec.resolve(&defaults).unwrap();
19245 let receipt = shared
19246 .resolve_environment_profile(&resolved)
19247 .expect("stub resolve")
19248 .expect("env profile");
19249 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19250 }
19251
19252
19253 #[test]
19256 fn network_allowlist_catalog_loads_from_temp_file() {
19257 let unique = std::time::SystemTime::now()
19258 .duration_since(std::time::UNIX_EPOCH)
19259 .unwrap()
19260 .as_nanos();
19261 let path = std::env::temp_dir().join(format!(
19262 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19263 std::process::id(),
19264 ));
19265 std::fs::write(
19266 &path,
19267 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19268 )
19269 .unwrap();
19270 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19271 .expect("temp catalog file must load");
19272 let _ = std::fs::remove_file(&path);
19273 assert_eq!(loaded.len(), 2);
19274 let (shared, defaults) = station_knobs_fixture();
19275 {
19276 let mut catalog = shared
19277 .network_allowlist_catalog
19278 .write()
19279 .unwrap_or_else(|poisoned| poisoned.into_inner());
19280 *catalog = loaded;
19281 }
19282 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19283 let spec = crate::protocol::SpawnSpec {
19284 target: crate::protocol::SpawnTarget {
19285 node_id: shared.node_id.clone(),
19286 workspace_id: WorkspaceId::new("primary").unwrap(),
19287 worktree_id: None,
19288 },
19289 profile_id: defaults.profile_id.clone(),
19290 expected_profile_revision: defaults.revision.clone(),
19291 overrides: crate::protocol::SpawnOverrides {
19292 network_allowlist: Some(allowlist.clone()),
19293 ..crate::protocol::SpawnOverrides::default()
19294 },
19295 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19296 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19297 "station-knobs-catalog-file",
19298 )
19299 .unwrap(),
19300 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19301 };
19302 let resolved = spec.resolve(&defaults).unwrap();
19303 let receipt = shared
19304 .resolve_environment_profile(&resolved)
19305 .expect("registered via file load")
19306 .expect("env profile present");
19307 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19308 }
19309
19310 #[test]
19313 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19314 let (shared, _defaults) = station_knobs_fixture();
19315 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19316 shared
19317 .network_allowlist_catalog
19318 .write()
19319 .unwrap_or_else(|poisoned| poisoned.into_inner())
19320 .insert_id(allowlist.clone())
19321 .unwrap();
19322 let snapshot = shared.snapshot();
19323 let inventory = snapshot
19324 .launch_inventory
19325 .expect("launch inventory present");
19326 let ids = inventory
19327 .network_allowlists
19328 .expect("non-empty catalog surfaces ids");
19329 assert_eq!(ids, vec![allowlist]);
19330 }
19331
19332 #[test]
19336 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19337 let (shared, defaults) = station_knobs_fixture();
19338 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19339 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19340 id: allowlist.clone(),
19341 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19342 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19343 peer: "127.0.0.1:443".to_owned(),
19344 }],
19345 provider_native: Some(
19346 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19347 codex_network_access: Some(true),
19348 },
19349 ),
19350 };
19351 shared
19352 .network_allowlist_catalog
19353 .write()
19354 .unwrap_or_else(|poisoned| poisoned.into_inner())
19355 .insert(entry)
19356 .unwrap();
19357 let spec = crate::protocol::SpawnSpec {
19358 target: crate::protocol::SpawnTarget {
19359 node_id: shared.node_id.clone(),
19360 workspace_id: WorkspaceId::new("primary").unwrap(),
19361 worktree_id: None,
19362 },
19363 profile_id: defaults.profile_id.clone(),
19364 expected_profile_revision: defaults.revision.clone(),
19365 overrides: crate::protocol::SpawnOverrides {
19366 network_allowlist: Some(allowlist),
19367 ..crate::protocol::SpawnOverrides::default()
19368 },
19369 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19370 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19371 "station-knobs-unsupported-mapping",
19372 )
19373 .unwrap(),
19374 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19375 };
19376 let resolved = spec.resolve(&defaults).unwrap();
19377 assert_eq!(resolved.provider.as_str(), "claude");
19378 let failure = shared
19379 .resolve_environment_profile(&resolved)
19380 .expect_err("claude + codex_network_access must refuse");
19381 assert_eq!(
19382 failure.code,
19383 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19384 );
19385 }
19386
19387 #[test]
19391 fn resolve_codex_network_access_overlay_moderate_only() {
19392 let (shared, defaults) = station_knobs_fixture();
19393 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19394 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19395 id: allowlist.clone(),
19396 permits: Vec::new(),
19397 provider_native: Some(
19398 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19399 codex_network_access: Some(true),
19400 },
19401 ),
19402 };
19403 shared
19404 .network_allowlist_catalog
19405 .write()
19406 .unwrap_or_else(|poisoned| poisoned.into_inner())
19407 .insert(entry)
19408 .unwrap();
19409
19410 let overlay = shared
19411 .resolve_codex_network_access_overlay_args(
19412 &agent("codex"),
19413 SessionMode::Pty,
19414 ApprovalLevel::Moderate,
19415 Some(&allowlist),
19416 )
19417 .expect("Moderate Codex maps network_access");
19418 assert_eq!(
19419 overlay,
19420 vec![
19421 std::ffi::OsString::from("-c"),
19422 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19423 ]
19424 );
19425
19426 let acp = shared
19427 .resolve_codex_network_access_overlay_args(
19428 &agent("codex"),
19429 SessionMode::Acp,
19430 ApprovalLevel::Moderate,
19431 Some(&allowlist),
19432 )
19433 .unwrap();
19434 assert_eq!(acp, overlay);
19435
19436 let inline = shared
19437 .resolve_codex_network_access_overlay_args(
19438 &agent("codex"),
19439 SessionMode::Inline,
19440 ApprovalLevel::Moderate,
19441 Some(&allowlist),
19442 )
19443 .unwrap_err();
19444 assert_eq!(
19445 inline.code,
19446 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19447 );
19448
19449 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19450 let spec = crate::protocol::SpawnSpec {
19451 target: crate::protocol::SpawnTarget {
19452 node_id: shared.node_id.clone(),
19453 workspace_id: WorkspaceId::new("primary").unwrap(),
19454 worktree_id: None,
19455 },
19456 profile_id: defaults.profile_id.clone(),
19457 expected_profile_revision: defaults.revision.clone(),
19458 overrides: crate::protocol::SpawnOverrides {
19459 provider: crate::protocol::SpawnOverride::Set {
19460 value: agent("codex"),
19461 },
19462 approval_level: Some(level),
19463 network_allowlist: Some(allowlist.clone()),
19464 ..crate::protocol::SpawnOverrides::default()
19465 },
19466 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19467 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19468 "codex-net-refuse-{}",
19469 match level {
19470 ApprovalLevel::ReadOnly => "ro",
19471 ApprovalLevel::FullAuto => "fa",
19472 _ => "other",
19473 }
19474 ))
19475 .unwrap(),
19476 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19477 };
19478 let resolved = spec.resolve(&defaults).unwrap();
19479 let failure = shared
19480 .resolve_environment_profile(&resolved)
19481 .expect_err("RO/FullAuto must refuse");
19482 assert_eq!(
19483 failure.code,
19484 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19485 );
19486 }
19487 }
19488
19489 #[test]
19493 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19494 let (shared, _defaults) = station_knobs_fixture();
19495 let allowlist =
19496 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19497 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19498 id: allowlist.clone(),
19499 permits: vec![
19500 crate::network_allowlist_catalog::NetworkPermitSketch {
19501 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19502 peer: "github.com:443".to_owned(),
19503 },
19504 crate::network_allowlist_catalog::NetworkPermitSketch {
19505 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19506 peer: "*.npmjs.org".to_owned(),
19507 },
19508 ],
19509 provider_native: None,
19510 };
19511 shared
19512 .network_allowlist_catalog
19513 .write()
19514 .unwrap_or_else(|poisoned| poisoned.into_inner())
19515 .insert(entry)
19516 .unwrap();
19517
19518 let unique = std::time::SystemTime::now()
19519 .duration_since(std::time::UNIX_EPOCH)
19520 .unwrap()
19521 .as_nanos();
19522 let provider_home = std::env::temp_dir().join(format!(
19523 "gate4agent-claude-provider-home-{}-{unique}",
19524 std::process::id(),
19525 ));
19526 std::fs::create_dir_all(&provider_home).unwrap();
19527
19528 let overlay = shared
19529 .resolve_network_allowlist_overlay_args(
19530 &agent("claude"),
19531 SessionMode::Pty,
19532 ApprovalLevel::Moderate,
19533 Some(&allowlist),
19534 Some(provider_home.as_path()),
19535 )
19536 .expect("Claude PTY settings overlay on unix");
19537 let settings_path = provider_home.join(
19538 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19539 );
19540 assert_eq!(
19541 overlay,
19542 vec![
19543 std::ffi::OsString::from("--settings"),
19544 std::ffi::OsString::from(settings_path.as_os_str()),
19545 ]
19546 );
19547 let body = std::fs::read_to_string(&settings_path).unwrap();
19548 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19549 assert_eq!(value["sandbox"]["enabled"], true);
19550 assert_eq!(
19551 value["sandbox"]["network"]["allowedDomains"],
19552 serde_json::json!(["github.com", "*.npmjs.org"])
19553 );
19554 assert!(settings_path.starts_with(&provider_home));
19555
19556 let acp = shared
19557 .resolve_network_allowlist_overlay_args(
19558 &agent("claude"),
19559 SessionMode::Acp,
19560 ApprovalLevel::Moderate,
19561 Some(&allowlist),
19562 Some(provider_home.as_path()),
19563 )
19564 .expect_err("Claude ACP must refuse settings overlay");
19565 assert_eq!(
19566 acp.code,
19567 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19568 );
19569
19570 let no_home = shared
19571 .resolve_network_allowlist_overlay_args(
19572 &agent("claude"),
19573 SessionMode::Pty,
19574 ApprovalLevel::Moderate,
19575 Some(&allowlist),
19576 None,
19577 )
19578 .expect_err("Claude overlay requires ProviderHome");
19579 assert_eq!(
19580 no_home.code,
19581 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19582 );
19583
19584 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19585 shared
19586 .network_allowlist_catalog
19587 .write()
19588 .unwrap_or_else(|poisoned| poisoned.into_inner())
19589 .insert_id(bare.clone())
19590 .unwrap();
19591 let empty = shared
19592 .resolve_network_allowlist_overlay_args(
19593 &agent("claude"),
19594 SessionMode::Pty,
19595 ApprovalLevel::Moderate,
19596 Some(&bare),
19597 Some(provider_home.as_path()),
19598 )
19599 .unwrap();
19600 assert!(empty.is_empty());
19601
19602 let _ = std::fs::remove_dir_all(&provider_home);
19603 }
19604
19605 #[test]
19607 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19608 let (shared, _defaults) = station_knobs_fixture();
19609 let allowlist =
19610 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19611 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19612 id: allowlist.clone(),
19613 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19614 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19615 peer: "example.com:443".to_owned(),
19616 }],
19617 provider_native: None,
19618 };
19619 shared
19620 .network_allowlist_catalog
19621 .write()
19622 .unwrap_or_else(|poisoned| poisoned.into_inner())
19623 .insert(entry)
19624 .unwrap();
19625 for provider in ["kimi", "grok"] {
19626 let overlay = shared
19627 .resolve_network_allowlist_overlay_args(
19628 &agent(provider),
19629 SessionMode::Pty,
19630 ApprovalLevel::Moderate,
19631 Some(&allowlist),
19632 None,
19633 )
19634 .unwrap();
19635 assert!(
19636 overlay.is_empty(),
19637 "{provider} must not invent network argv from permits"
19638 );
19639 }
19640 }
19641
19642 #[test]
19644 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19645 let unique = std::time::SystemTime::now()
19646 .duration_since(std::time::UNIX_EPOCH)
19647 .unwrap()
19648 .as_nanos();
19649 let path = std::env::temp_dir().join(format!(
19650 "gate4agent-node-allowlist-json-{}-{unique}.json",
19651 std::process::id(),
19652 ));
19653 std::fs::write(
19654 &path,
19655 r#"{
19656 "schema_version": 2,
19657 "entries": [
19658 {
19659 "id": "egress-default",
19660 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19661 },
19662 { "id": "lab-egress" }
19663 ]
19664 }"#,
19665 )
19666 .unwrap();
19667 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19668 .expect("json v2 catalog must load");
19669 let _ = std::fs::remove_file(&path);
19670 assert_eq!(loaded.len(), 2);
19671 let entry = loaded
19672 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19673 .unwrap();
19674 assert_eq!(entry.permits.len(), 1);
19675 let (shared, defaults) = station_knobs_fixture();
19676 {
19677 let mut catalog = shared
19678 .network_allowlist_catalog
19679 .write()
19680 .unwrap_or_else(|poisoned| poisoned.into_inner());
19681 *catalog = loaded;
19682 }
19683 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19684 let spec = crate::protocol::SpawnSpec {
19685 target: crate::protocol::SpawnTarget {
19686 node_id: shared.node_id.clone(),
19687 workspace_id: WorkspaceId::new("primary").unwrap(),
19688 worktree_id: None,
19689 },
19690 profile_id: defaults.profile_id.clone(),
19691 expected_profile_revision: defaults.revision.clone(),
19692 overrides: crate::protocol::SpawnOverrides {
19693 network_allowlist: Some(allowlist.clone()),
19694 ..crate::protocol::SpawnOverrides::default()
19695 },
19696 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19697 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19698 "station-knobs-catalog-json",
19699 )
19700 .unwrap(),
19701 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19702 };
19703 let resolved = spec.resolve(&defaults).unwrap();
19704 let receipt = shared
19705 .resolve_environment_profile(&resolved)
19706 .expect("permits-only entry ok for claude")
19707 .expect("env profile present");
19708 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19709 let inventory = shared
19710 .snapshot()
19711 .launch_inventory
19712 .expect("launch inventory present");
19713 let ids = inventory.network_allowlists.expect("ids surface");
19714 assert_eq!(
19715 ids,
19716 vec![
19717 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19718 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19719 ]
19720 );
19721 }
19722
19723
19724}
19725
19726
19727#[cfg(test)]
19728mod workspace_environment_collision_tests {
19729 use super::*;
19730 use crate::session_environment::{
19731 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19732 };
19733 use gate4agent_catalog::EnvMutation;
19734 use gate4agent_runtime_native::{
19735 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19736 NativeLaunchProfileId,
19737 };
19738 use std::ffi::OsString;
19739 use std::sync::Arc;
19740
19741 struct EmptyEnvironmentResolver;
19742
19743 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19744 fn resolve_child_environment(
19745 &self,
19746 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19747 Ok(vec![EnvMutation {
19748 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19749 value: None,
19750 }])
19751 }
19752 }
19753
19754 fn agent(value: &str) -> AgentId {
19755 AgentId::new(value).unwrap()
19756 }
19757
19758 fn claude_config_dir_collision_fixture() -> (
19760 NodeShared,
19761 ResolvedEnvironmentProfileReceipt,
19762 ResolvedEnvironmentProfileReceipt,
19763 ) {
19764 let catalog = active_registry().unwrap();
19765 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19766 let control = runtime.native_launch_profile_control();
19767 let workspace = WorkspaceConfig::new(
19768 WorkspaceId::new("primary").unwrap(),
19769 std::env::current_dir().unwrap(),
19770 )
19771 .unwrap();
19772 let shared = NodeShared::new_with_incarnation(
19773 handle,
19774 "fixture-token".to_owned(),
19775 NodeId::new("node-env-collision-test").unwrap(),
19776 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19777 vec![workspace],
19778 vec![agent("claude")],
19779 ProviderRuntimeStatuses::default(),
19780 None,
19781 Vec::new(),
19782 Vec::new(),
19783 SpawnProfileRegistry::default(),
19784 Some(control),
19785 None,
19786 None,
19787 None,
19788 Vec::new(),
19789 Vec::new(),
19790 Vec::new(),
19791 Vec::new(),
19792 None,
19793 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19794 );
19795
19796 let mut receipts = Vec::new();
19797 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19798 let profile_id =
19799 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19800 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19801 "claude-config-dir-{suffix}-r1"
19802 ))
19803 .unwrap();
19804 let materialization = NodeSessionMaterializationProfile::new(
19805 Vec::new(),
19806 vec![NodeSessionPathBinding::new(
19807 "CLAUDE_CONFIG_DIR",
19808 NodeSessionPathClass::ProviderHome,
19809 )
19810 .unwrap()],
19811 Vec::new(),
19812 )
19813 .unwrap();
19814 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19815 profile_id.clone(),
19816 profile_revision.clone(),
19817 agent("claude"),
19818 [NativeLaunchProfile::new(
19819 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19820 .unwrap(),
19821 agent("claude"),
19822 TransportKind::Pty,
19823 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19824 Arc::new(EmptyEnvironmentResolver),
19825 )
19826 .unwrap()],
19827 Some(materialization),
19828 )
19829 .unwrap();
19830 let (binding, native_profiles, materialization) = node_profile.into_parts();
19831 for native_profile in native_profiles {
19832 runtime.upsert_native_launch_profile(native_profile).unwrap();
19833 }
19834 shared
19835 .environment_profiles
19836 .write()
19837 .unwrap_or_else(|poisoned| poisoned.into_inner())
19838 .insert(binding.id.clone(), binding);
19839 shared
19840 .environment_materialization_profiles
19841 .write()
19842 .unwrap_or_else(|poisoned| poisoned.into_inner())
19843 .insert(profile_id.clone(), materialization.unwrap());
19844 receipts.push(ResolvedEnvironmentProfileReceipt {
19845 profile_id,
19846 profile_revision,
19847 network_allowlist: None,
19848 browser_profile_id: None,
19849 });
19850 }
19851 let profile_b = receipts.pop().unwrap();
19852 let profile_a = receipts.pop().unwrap();
19853 (shared, profile_a, profile_b)
19854 }
19855
19856 #[test]
19857 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19858 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19859 let primary = WorkspaceId::new("primary").unwrap();
19860 let worktree = WorkspaceId::new("worktree-b").unwrap();
19861 let address = SessionAddress {
19862 workspace_id: primary.clone(),
19863 session: SessionKey {
19864 instance_id: AgentInstanceId(41),
19865 generation: SessionGeneration(1),
19866 },
19867 };
19868 shared.bind_session_with_policy(
19869 &address,
19870 ProviderRuntimePolicy::raw_pty(),
19871 Some(profile_a.clone()),
19872 );
19873
19874 let refused = shared
19875 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19876 .unwrap_err();
19877 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19878
19879 let refused_none = shared
19881 .ensure_workspace_environment_profile_compatible(&primary, None)
19882 .unwrap_err();
19883 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19884
19885 shared
19887 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19888 .unwrap();
19889
19890 shared
19892 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19893 .unwrap();
19894 }
19895
19896 #[tokio::test]
19897 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19898 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19899 let primary = WorkspaceId::new("primary").unwrap();
19900 let address = SessionAddress {
19901 workspace_id: primary.clone(),
19902 session: SessionKey {
19903 instance_id: AgentInstanceId(41),
19904 generation: SessionGeneration(1),
19905 },
19906 };
19907 shared.bind_session_with_policy(
19908 &address,
19909 ProviderRuntimePolicy::raw_pty(),
19910 Some(profile_a),
19911 );
19912
19913 let failure = shared
19914 .spawn_session_with_deadline(
19915 primary,
19916 agent("claude"),
19917 SessionMode::Pty,
19918 gate4agent_types::TerminalSize {
19919 rows: 24,
19920 columns: 80,
19921 },
19922 None,
19923 Some(profile_b),
19924 None,
19925 None,
19926 None,
19927 None,
19928 SpawnRecordPolicy::ProviderIdentityOnly,
19929 Some(Instant::now() + Duration::from_secs(30)),
19930 &[],
19931 None,
19932 ApprovalLevel::default(),
19933 None,
19934 None,
19935 )
19936 .await
19937 .unwrap_err();
19938 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19939 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19941 assert!(shared.handle.snapshot().sessions.is_empty());
19942 }
19943}
19944
19945#[cfg(test)]
19946mod spawn_characterization_tests {
19947 use super::*;
19948 use crate::session_environment::{
19949 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19950 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19951 NodeSessionPathClass,
19952 };
19953 use gate4agent_catalog::EnvMutation;
19954 use gate4agent_runtime_native::{
19955 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19956 NativeLaunchProfile, NativeLaunchProfileId,
19957 };
19958 use std::ffi::OsString;
19959 use std::sync::Arc;
19960
19961 struct EmptyEnvironmentResolver;
19962
19963 struct FixtureSecretResolver {
19964 deny: Arc<AtomicBool>,
19965 }
19966
19967 impl NodeSecretResolver for FixtureSecretResolver {
19968 fn resolve(
19969 &self,
19970 _reference: &NodeSecretReference,
19971 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19972 if self.deny.load(Ordering::Acquire) {
19973 Err(NodeSecretResolveError::Denied)
19974 } else {
19975 NodeSecretValue::text("fixture-secret")
19976 .map_err(|_| NodeSecretResolveError::Unavailable)
19977 }
19978 }
19979 }
19980
19981 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19982 fn resolve_child_environment(
19983 &self,
19984 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19985 Ok(vec![EnvMutation {
19986 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19987 value: None,
19988 }])
19989 }
19990 }
19991
19992 fn agent(value: &str) -> AgentId {
19993 AgentId::new(value).unwrap()
19994 }
19995
19996 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19997 let unique = std::time::SystemTime::now()
19998 .duration_since(std::time::UNIX_EPOCH)
19999 .unwrap()
20000 .as_nanos();
20001 std::env::temp_dir().join(format!(
20002 "gate4agent-node-{label}-{}-{unique}",
20003 std::process::id(),
20004 ))
20005 }
20006
20007 fn materialization_test_shared(
20008 deny_secret: bool,
20009 ) -> (
20010 NodeShared,
20011 NativeRuntime,
20012 ResolvedEnvironmentProfileReceipt,
20013 PathBuf,
20014 Arc<AtomicBool>,
20015 ) {
20016 let workspace = WorkspaceConfig::new(
20017 WorkspaceId::new("primary").unwrap(),
20018 std::env::current_dir().unwrap(),
20019 )
20020 .unwrap();
20021 materialization_test_shared_with_workspace(deny_secret, workspace)
20022 }
20023
20024 fn materialization_test_shared_with_workspace(
20025 deny_secret: bool,
20026 workspace: WorkspaceConfig,
20027 ) -> (
20028 NodeShared,
20029 NativeRuntime,
20030 ResolvedEnvironmentProfileReceipt,
20031 PathBuf,
20032 Arc<AtomicBool>,
20033 ) {
20034 let root = temporary_workspace_root(if deny_secret {
20035 "materialization-denied"
20036 } else {
20037 "materialization-ready"
20038 });
20039 std::fs::create_dir_all(&root).unwrap();
20040 let materialization_root = root.join("session-environments");
20041 let deny = Arc::new(AtomicBool::new(deny_secret));
20042 let resolver = Arc::new(FixtureSecretResolver {
20043 deny: Arc::clone(&deny),
20044 });
20045 let materializer = SessionEnvironmentMaterializer::new(
20046 materialization_root,
20047 resolver,
20048 )
20049 .unwrap();
20050 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20051 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20052 "materialized-claude-r1",
20053 )
20054 .unwrap();
20055 let materialization = NodeSessionMaterializationProfile::new(
20056 vec![NodeSessionEnvironmentMutation::SetSecret {
20057 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20058 reference: NodeSecretReference::new("fixture-token").unwrap(),
20059 }],
20060 vec![NodeSessionPathBinding::new(
20061 "GATE4AGENT_PROVIDER_HOME",
20062 NodeSessionPathClass::ProviderHome,
20063 )
20064 .unwrap()],
20065 vec![NodeSessionFile::secret(
20066 NodeSessionPathClass::Config,
20067 "auth/token",
20068 NodeSecretReference::new("fixture-token").unwrap(),
20069 )
20070 .unwrap()],
20071 )
20072 .unwrap();
20073 let catalog = active_registry().unwrap();
20074 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20075 let control = runtime.native_launch_profile_control();
20076 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20077 profile_id.clone(),
20078 profile_revision.clone(),
20079 agent("claude"),
20080 [NativeLaunchProfile::new(
20081 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20082 agent("claude"),
20083 TransportKind::Pty,
20084 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20085 Arc::new(EmptyEnvironmentResolver),
20086 )
20087 .unwrap()],
20088 Some(materialization),
20089 )
20090 .unwrap();
20091 let (binding, native_profiles, materialization) = node_profile.into_parts();
20092 for native_profile in native_profiles {
20093 runtime.upsert_native_launch_profile(native_profile).unwrap();
20094 }
20095 let shared = NodeShared::new_with_incarnation(
20096 handle,
20097 "fixture-token".to_owned(),
20098 NodeId::new("node-materialization-test").unwrap(),
20099 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20100 vec![workspace],
20101 vec![agent("claude")],
20102 ProviderRuntimeStatuses::default(),
20103 None,
20104 Vec::new(),
20105 Vec::new(),
20106 SpawnProfileRegistry::default(),
20107 Some(control),
20108 None,
20109 Some(materializer),
20110 None,
20111 Vec::new(),
20112 Vec::new(),
20113 Vec::new(),
20114 Vec::new(),
20115 None,
20116 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20117 );
20118 shared
20119 .environment_profiles
20120 .write()
20121 .unwrap_or_else(|poisoned| poisoned.into_inner())
20122 .insert(binding.id.clone(), binding);
20123 shared
20124 .environment_materialization_profiles
20125 .write()
20126 .unwrap_or_else(|poisoned| poisoned.into_inner())
20127 .insert(profile_id.clone(), materialization.unwrap());
20128 (
20129 shared,
20130 runtime,
20131 ResolvedEnvironmentProfileReceipt {
20132 profile_id,
20133 profile_revision,
20134 network_allowlist: None,
20135 browser_profile_id: None,
20136 },
20137 root,
20138 deny,
20139 )
20140 }
20141
20142
20143 async fn characterization_spawn(
20146 shared: &NodeShared,
20147 mode: SessionMode,
20148 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20149 required: &[ProviderRuntimeCapability],
20150 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20151 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20152 shared
20153 .spawn_session_with_deadline(
20154 WorkspaceId::new("primary").unwrap(),
20155 agent("claude"),
20156 mode,
20157 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20158 None,
20159 environment_profile,
20160 None,
20161 None,
20162 None,
20163 Some(ProviderRuntimePolicy::raw_pty()),
20164 SpawnRecordPolicy::ProviderIdentityOnly,
20165 Some(Instant::now() + Duration::from_secs(30)),
20166 required,
20167 harness_mcp,
20168 ApprovalLevel::default(),
20169 None,
20170 None,
20171 )
20172 .await
20173 }
20174
20175 fn assert_no_session_state(shared: &NodeShared) {
20176 assert!(shared.handle.snapshot().sessions.is_empty());
20177 assert!(shared
20178 .session_bindings
20179 .lock()
20180 .unwrap_or_else(|poisoned| poisoned.into_inner())
20181 .is_empty());
20182 assert!(shared
20183 .materializations
20184 .lock()
20185 .unwrap_or_else(|poisoned| poisoned.into_inner())
20186 .is_empty());
20187 }
20188
20189 #[tokio::test]
20190 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20191 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20192 let error = characterization_spawn(
20193 &shared,
20194 SessionMode::Pty,
20195 None,
20196 &[ProviderRuntimeCapability::SemanticReadiness],
20197 None,
20198 )
20199 .await
20200 .unwrap_err();
20201 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20202 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20203 assert_no_session_state(&shared);
20204 drop(shared);
20205 drop(runtime);
20206 std::fs::remove_dir_all(root).unwrap();
20207 }
20208
20209 #[tokio::test]
20210 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20211 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20212 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20213 .await
20214 .unwrap_err();
20215 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20216 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20217 assert_no_session_state(&shared);
20218 drop(shared);
20219 drop(runtime);
20220 std::fs::remove_dir_all(root).unwrap();
20221 }
20222
20223 #[test]
20224 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20225 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20226 let address = SessionAddress {
20227 workspace_id: WorkspaceId::new("primary").unwrap(),
20228 session: SessionKey {
20229 instance_id: AgentInstanceId(77),
20230 generation: SessionGeneration(1),
20231 },
20232 };
20233 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20234 let removed = shared.remove_binding(&address).expect("binding removed");
20235 assert!(removed.environment_profile.is_some());
20236 assert!(shared.remove_binding(&address).is_none());
20237 assert_no_session_state(&shared);
20238 drop(shared);
20239 drop(runtime);
20240 std::fs::remove_dir_all(root).unwrap();
20241 }
20242
20243 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20244 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20245 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20246 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20247 .expect("review current test executable as fixture helper");
20248 let registry = HarnessMcpProxyRegistry::new(helper);
20249 let spec = crate::protocol::SpawnSpec {
20250 target: crate::protocol::SpawnTarget {
20251 node_id: NodeId::new("node-characterization").unwrap(),
20252 workspace_id: WorkspaceId::new("primary").unwrap(),
20253 worktree_id: None,
20254 },
20255 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20256 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20257 overrides: crate::protocol::SpawnOverrides::default(),
20258 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20259 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20260 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20261 };
20262 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20263 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20264 let launch = HarnessMcpLaunchV1 {
20265 server_name: "fixture-harness".to_owned(),
20266 args: vec!["--session-proxy".to_owned()],
20267 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20268 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20269 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20270 trace: None,
20271 scrub_env: Vec::new(),
20272 };
20273 let expires = unix_time_ms().saturating_add(30_000);
20274 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20275 registry
20276 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20277 .unwrap()
20278 }
20279
20280 #[tokio::test]
20281 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20282 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20283 let prepared = characterization_prepared_harness("claude").await;
20284 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20285 .await
20286 .unwrap_err();
20287 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20289 let mismatched = characterization_prepared_harness("codex").await;
20290 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20291 .await
20292 .unwrap_err();
20293 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20294 assert_no_session_state(&shared);
20295 drop(shared);
20296 drop(runtime);
20297 std::fs::remove_dir_all(root).unwrap();
20298 }
20299
20300 #[tokio::test]
20301 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20302 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20303 shared.native_launch_profile_control = None;
20304 let prepared = characterization_prepared_harness("claude").await;
20305 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20306 .await
20307 .unwrap_err();
20308 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20309 assert_no_session_state(&shared);
20310 drop(shared);
20311 drop(runtime);
20312 std::fs::remove_dir_all(root).unwrap();
20313 }
20314}
20315
20316#[cfg(all(test, windows))]
20317mod tests {
20318 use super::*;
20319 use gate4agent_types::ProviderActivity;
20320 use crate::session_environment::{
20321 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20322 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20323 NodeSessionPathClass,
20324 };
20325 use gate4agent_catalog::EnvMutation;
20326 use gate4agent_runtime_native::{
20327 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20328 NativeLaunchProfile, NativeLaunchProfileId,
20329 };
20330 use std::ffi::OsString;
20331 use std::sync::Arc;
20332
20333 struct EmptyEnvironmentResolver;
20334
20335 struct FixtureSecretResolver {
20336 deny: Arc<AtomicBool>,
20337 }
20338
20339 impl NodeSecretResolver for FixtureSecretResolver {
20340 fn resolve(
20341 &self,
20342 _reference: &NodeSecretReference,
20343 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20344 if self.deny.load(Ordering::Acquire) {
20345 Err(NodeSecretResolveError::Denied)
20346 } else {
20347 NodeSecretValue::text("fixture-secret")
20348 .map_err(|_| NodeSecretResolveError::Unavailable)
20349 }
20350 }
20351 }
20352
20353 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20354 fn resolve_child_environment(
20355 &self,
20356 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20357 Ok(vec![EnvMutation {
20358 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20359 value: None,
20360 }])
20361 }
20362 }
20363
20364 fn agent(value: &str) -> AgentId {
20365 AgentId::new(value).unwrap()
20366 }
20367
20368 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20369 let catalog = active_registry().unwrap();
20370 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20371 let workspace = WorkspaceConfig::new(
20372 WorkspaceId::new("primary").unwrap(),
20373 std::env::current_dir().unwrap(),
20374 )
20375 .unwrap()
20376 .with_worktree_service_mode(mode);
20377 NodeShared::new(
20378 handle,
20379 "fixture-token".to_owned(),
20380 NodeId::new("node-terminal-test").unwrap(),
20381 vec![workspace],
20382 vec![agent("claude")],
20383 )
20384 }
20385
20386 fn terminal_test_shared() -> NodeShared {
20387 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20388 }
20389
20390 #[cfg(feature = "fixture")]
20391 #[test]
20392 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20393 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20394 assert_eq!(probe.latest_code(), None);
20395
20396 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20397 assert_eq!(
20398 probe.latest_code(),
20399 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20400 );
20401
20402 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20403 assert_eq!(
20404 probe.latest_code(),
20405 Some(NodeFailureCode::SpawnDeadlineExceeded),
20406 );
20407
20408 probe.record(None);
20409 assert_eq!(probe.latest_code(), None);
20410 }
20411
20412 #[test]
20413 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20414 let source = temporary_workspace_root("launch-inventory-source");
20415 let allocation = temporary_workspace_root("launch-inventory-allocation");
20416 std::fs::create_dir_all(&source).unwrap();
20417 std::fs::create_dir_all(&allocation).unwrap();
20418 let workspace_id = WorkspaceId::new("primary").unwrap();
20419 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20420 .unwrap()
20421 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20422 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20423 workspace = workspace
20424 .with_managed_worktree_profile(
20425 ManagedWorktreeProfile::new(
20426 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20427 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20428 &allocation,
20429 "gate4agent",
20430 "HEAD",
20431 ManagedWorktreeRetention::RemoveWhenReleased,
20432 )
20433 .unwrap(),
20434 )
20435 .unwrap();
20436 }
20437 let overflow = workspace.clone().with_managed_worktree_profile(
20438 ManagedWorktreeProfile::new(
20439 WorktreeProfileId::new("overflow").unwrap(),
20440 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20441 &allocation,
20442 "gate4agent",
20443 "HEAD",
20444 ManagedWorktreeRetention::Retain,
20445 )
20446 .unwrap(),
20447 );
20448 assert!(matches!(
20449 overflow,
20450 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20451 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20452 ..
20453 }),
20454 ));
20455
20456 let catalog = active_registry().unwrap();
20457 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20458 let shared = NodeShared::new(
20459 handle,
20460 "fixture-token".to_owned(),
20461 NodeId::new("node-launch-inventory").unwrap(),
20462 vec![workspace],
20463 vec![agent("claude")],
20464 );
20465 let snapshot = shared.snapshot();
20466 let launch = snapshot.launch_inventory.as_ref().unwrap();
20467 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20468 assert!(launch.bundles.as_ref().unwrap().is_empty());
20469 let profiles = snapshot.workspaces[0]
20470 .managed_worktree_profiles
20471 .as_ref()
20472 .unwrap();
20473 assert_eq!(
20474 snapshot.workspaces[0].worktree_service_mode,
20475 Some(WorktreeServiceMode::Managed),
20476 );
20477 assert_eq!(
20478 profiles.profiles.len(),
20479 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20480 );
20481 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20482 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20483 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20484 }
20485 let legacy = snapshot_for_wire(
20486 &shared,
20487 true,
20488 true,
20489 false,
20490 false,
20491 true,
20492 false,
20493 true,
20494 false,
20495 true,
20496 );
20497 assert!(legacy.launch_inventory.is_none());
20498 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20499
20500 drop(shared);
20501 std::fs::remove_dir_all(source).unwrap();
20502 std::fs::remove_dir_all(allocation).unwrap();
20503 }
20504
20505 #[test]
20506 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20507 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20508 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20509 }));
20510 let source = gate4agent_types::ProviderSource {
20511 family: AdapterFamily::PtySemantic,
20512 binding: AdapterBinding::new(
20513 gate4agent_types::AdapterId::new("claude").unwrap(),
20514 "fixture/v1",
20515 gate4agent_types::AdapterVerification::SyntheticFixture,
20516 )
20517 .unwrap(),
20518 };
20519 let provider = ProviderSnapshot {
20520 sequence: 17,
20521 session: Some(ProviderSessionIdentity {
20522 key: ProviderSessionKey::SessionId,
20523 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20524 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20525 }),
20526 model: Some("PRIVATE_MODEL".to_owned()),
20527 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20528 completed_turns: 3,
20529 usage: gate4agent_types::TokenUsage {
20530 input_tokens: 10,
20531 output_tokens: 20,
20532 cache_read_tokens: 30,
20533 cache_write_tokens: 40,
20534 reasoning_tokens: 50,
20535 context_window: Some(200_000),
20536 },
20537 lead_activity: ProviderActivity::WaitingForInput,
20538 activity: ProviderActivity::WaitingForInput,
20539 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20540 active_tools: (0..9)
20541 .map(|index| gate4agent_types::ActiveProviderTool {
20542 id: format!("private-tool-id-{index}"),
20543 name: if index == 0 {
20544 format!(" Read\u{0000}{}", "x".repeat(80))
20545 } else {
20546 format!("Tool-{index}")
20547 },
20548 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20549 })
20550 .collect(),
20551 interactions: vec![gate4agent_types::ProviderInteraction {
20552 id: gate4agent_types::ProviderInteractionId(17),
20553 source: source.clone(),
20554 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20555 interaction_kind: ProviderInteractionKind::Approval,
20556 tool_name: " Approve\u{0007}Tool ".to_owned(),
20557 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20558 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20559 resume_lead_activity: Some(ProviderActivity::Working),
20560 status: ProviderInteractionStatus::Pending,
20561 }],
20562 subagents: vec![gate4agent_types::ProviderSubagent {
20563 source: source.clone(),
20564 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20565 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20566 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20567 }],
20568 sources: vec![gate4agent_types::ProviderSourceCursor {
20569 source,
20570 sequence: 17,
20571 gap_count: 2,
20572 stale: true,
20573 }],
20574 last_event: Some(ProviderEvent::Error {
20575 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20576 }),
20577 gap_count: 2,
20578 stale: true,
20579 };
20580 let address = terminal_address(3);
20581 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20582 assert_eq!(entry.address, address);
20583 assert_eq!(entry.progress.provider_sequence, 17);
20584 assert_eq!(entry.progress.active_tool_count, 9);
20585 assert_eq!(
20586 entry.progress.active_tool_labels.len(),
20587 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20588 );
20589 assert_eq!(entry.progress.subagent_count, 1);
20590 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20591 assert!(entry.progress.truncated);
20592 let encoded = serde_json::to_string(&entry).unwrap();
20593 for forbidden in [
20594 "PRIVATE_PROVIDER_SESSION",
20595 "PRIVATE_PROVIDER_PATH",
20596 "PRIVATE_MODEL",
20597 "PRIVATE_TOOL_INVENTORY",
20598 "PRIVATE_CURRENT_PROMPT",
20599 "PRIVATE_TOOL_INPUT_JSON",
20600 "PRIVATE_PROVIDER_REQUEST",
20601 "PRIVATE_INTERACTION_PROMPT",
20602 "PRIVATE_INTERACTION_AGENT",
20603 "PRIVATE_SUBAGENT_ID",
20604 "PRIVATE_SUBAGENT_TYPE",
20605 "PRIVATE_SUBAGENT_DESCRIPTION",
20606 "PRIVATE_PROVIDER_ERROR",
20607 "input_json",
20608 "current_prompt",
20609 "session_id",
20610 ] {
20611 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20612 }
20613
20614 let snapshot = NodeSnapshot {
20615 node_id: NodeId::new("node-progress-test").unwrap(),
20616 enabled_providers: vec![agent("claude")],
20617 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20618 workspaces: Vec::new(),
20619 session_records: Vec::new(),
20620 managed_worktrees: Vec::new(),
20621 launch_inventory: None,
20622 agent_progress: vec![entry],
20623 };
20624 let mut legacy_reply = ResponseEnvelope {
20625 request_id: 1,
20626 result: Ok(NodeResponse::Snapshot {
20627 event_sequence: 0,
20628 controller: None,
20629 snapshot: snapshot.clone(),
20630 }),
20631 };
20632 project_response_without_agent_progress(&mut legacy_reply);
20633 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20634 panic!("agent progress projection changed response kind");
20635 };
20636 assert!(legacy.agent_progress.is_empty());
20637 assert_eq!(snapshot.agent_progress.len(), 1);
20638 }
20639
20640 fn terminal_address(generation: u64) -> SessionAddress {
20641 SessionAddress {
20642 workspace_id: WorkspaceId::new("primary").unwrap(),
20643 session: SessionKey {
20644 instance_id: AgentInstanceId(41),
20645 generation: SessionGeneration(generation),
20646 },
20647 }
20648 }
20649
20650 fn spawn_spec_fixture() -> SpawnSpec {
20651 SpawnSpec {
20652 target: crate::protocol::SpawnTarget {
20653 node_id: NodeId::new("node-terminal-test").unwrap(),
20654 workspace_id: WorkspaceId::new("primary").unwrap(),
20655 worktree_id: None,
20656 },
20657 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20658 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20659 .unwrap(),
20660 overrides: crate::protocol::SpawnOverrides::default(),
20661 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20662 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20663 required_capabilities: SpawnRequiredCapabilities::default(),
20664 }
20665 }
20666
20667 fn environment_profile_test_shared(
20668 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20669 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20670 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20671 "local-claude-r1",
20672 )
20673 .unwrap();
20674 let spawn_profiles = SpawnProfileRegistry::new([
20675 crate::protocol::SpawnProfileDefaults {
20676 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20677 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20678 provider: agent("claude"),
20679 mode: SessionMode::Pty,
20680 terminal_size: gate4agent_types::TerminalSize {
20681 rows: 24,
20682 columns: 80,
20683 },
20684 prompt: None,
20685 bundle_id: None,
20686 context_id: None,
20687 environment_profile_id: Some(profile_id.clone()),
20688 },
20689 ])
20690 .unwrap();
20691 let catalog = active_registry().unwrap();
20692 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20693 let control = runtime.native_launch_profile_control();
20694 let node_profile = NodeEnvironmentProfile::new(
20695 profile_id.clone(),
20696 profile_revision.clone(),
20697 agent("claude"),
20698 [NativeLaunchProfile::new(
20699 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20700 agent("claude"),
20701 TransportKind::Pty,
20702 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20703 Arc::new(EmptyEnvironmentResolver),
20704 )
20705 .unwrap()],
20706 )
20707 .unwrap();
20708 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20709 for native_profile in native_profiles {
20710 runtime.upsert_native_launch_profile(native_profile).unwrap();
20711 }
20712 let workspace = WorkspaceConfig::new(
20713 WorkspaceId::new("primary").unwrap(),
20714 std::env::current_dir().unwrap(),
20715 )
20716 .unwrap();
20717 let shared = NodeShared::new_with_incarnation(
20718 handle,
20719 "fixture-token".to_owned(),
20720 NodeId::new("node-terminal-test").unwrap(),
20721 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20722 vec![workspace],
20723 vec![agent("claude")],
20724 ProviderRuntimeStatuses::default(),
20725 None,
20726 Vec::new(),
20727 Vec::new(),
20728 spawn_profiles,
20729 Some(control),
20730 None,
20731 None,
20732 None,
20733 Vec::new(),
20734 Vec::new(),
20735 Vec::new(),
20736 Vec::new(),
20737 None,
20738 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20739 );
20740 shared
20741 .environment_profiles
20742 .write()
20743 .unwrap_or_else(|poisoned| poisoned.into_inner())
20744 .insert(binding.id.clone(), binding);
20745 (
20746 shared,
20747 runtime,
20748 ResolvedEnvironmentProfileReceipt {
20749 profile_id,
20750 profile_revision,
20751 network_allowlist: None,
20752 browser_profile_id: None,
20753 },
20754 )
20755 }
20756
20757 fn materialization_test_shared(
20758 deny_secret: bool,
20759 ) -> (
20760 NodeShared,
20761 NativeRuntime,
20762 ResolvedEnvironmentProfileReceipt,
20763 PathBuf,
20764 Arc<AtomicBool>,
20765 ) {
20766 let workspace = WorkspaceConfig::new(
20767 WorkspaceId::new("primary").unwrap(),
20768 std::env::current_dir().unwrap(),
20769 )
20770 .unwrap();
20771 materialization_test_shared_with_workspace(deny_secret, workspace)
20772 }
20773
20774 fn materialization_test_shared_with_workspace(
20775 deny_secret: bool,
20776 workspace: WorkspaceConfig,
20777 ) -> (
20778 NodeShared,
20779 NativeRuntime,
20780 ResolvedEnvironmentProfileReceipt,
20781 PathBuf,
20782 Arc<AtomicBool>,
20783 ) {
20784 let root = temporary_workspace_root(if deny_secret {
20785 "materialization-denied"
20786 } else {
20787 "materialization-ready"
20788 });
20789 std::fs::create_dir_all(&root).unwrap();
20790 let materialization_root = root.join("session-environments");
20791 let deny = Arc::new(AtomicBool::new(deny_secret));
20792 let resolver = Arc::new(FixtureSecretResolver {
20793 deny: Arc::clone(&deny),
20794 });
20795 let materializer = SessionEnvironmentMaterializer::new(
20796 materialization_root,
20797 resolver,
20798 )
20799 .unwrap();
20800 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20801 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20802 "materialized-claude-r1",
20803 )
20804 .unwrap();
20805 let materialization = NodeSessionMaterializationProfile::new(
20806 vec![NodeSessionEnvironmentMutation::SetSecret {
20807 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20808 reference: NodeSecretReference::new("fixture-token").unwrap(),
20809 }],
20810 vec![NodeSessionPathBinding::new(
20811 "GATE4AGENT_PROVIDER_HOME",
20812 NodeSessionPathClass::ProviderHome,
20813 )
20814 .unwrap()],
20815 vec![NodeSessionFile::secret(
20816 NodeSessionPathClass::Config,
20817 "auth/token",
20818 NodeSecretReference::new("fixture-token").unwrap(),
20819 )
20820 .unwrap()],
20821 )
20822 .unwrap();
20823 let catalog = active_registry().unwrap();
20824 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20825 let control = runtime.native_launch_profile_control();
20826 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20827 profile_id.clone(),
20828 profile_revision.clone(),
20829 agent("claude"),
20830 [NativeLaunchProfile::new(
20831 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20832 agent("claude"),
20833 TransportKind::Pty,
20834 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20835 Arc::new(EmptyEnvironmentResolver),
20836 )
20837 .unwrap()],
20838 Some(materialization),
20839 )
20840 .unwrap();
20841 let (binding, native_profiles, materialization) = node_profile.into_parts();
20842 for native_profile in native_profiles {
20843 runtime.upsert_native_launch_profile(native_profile).unwrap();
20844 }
20845 let shared = NodeShared::new_with_incarnation(
20846 handle,
20847 "fixture-token".to_owned(),
20848 NodeId::new("node-materialization-test").unwrap(),
20849 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20850 vec![workspace],
20851 vec![agent("claude")],
20852 ProviderRuntimeStatuses::default(),
20853 None,
20854 Vec::new(),
20855 Vec::new(),
20856 SpawnProfileRegistry::default(),
20857 Some(control),
20858 None,
20859 Some(materializer),
20860 None,
20861 Vec::new(),
20862 Vec::new(),
20863 Vec::new(),
20864 Vec::new(),
20865 None,
20866 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20867 );
20868 shared
20869 .environment_profiles
20870 .write()
20871 .unwrap_or_else(|poisoned| poisoned.into_inner())
20872 .insert(binding.id.clone(), binding);
20873 shared
20874 .environment_materialization_profiles
20875 .write()
20876 .unwrap_or_else(|poisoned| poisoned.into_inner())
20877 .insert(profile_id.clone(), materialization.unwrap());
20878 (
20879 shared,
20880 runtime,
20881 ResolvedEnvironmentProfileReceipt {
20882 profile_id,
20883 profile_revision,
20884 network_allowlist: None,
20885 browser_profile_id: None,
20886 },
20887 root,
20888 deny,
20889 )
20890 }
20891
20892 #[cfg(feature = "fixture")]
20893 #[derive(Clone, Copy)]
20894 enum CodexHomeProfile {
20895 Missing,
20896 WrongClass,
20897 Exact,
20898 }
20899
20900 #[cfg(feature = "fixture")]
20901 fn codex_bundle_test_shared(
20902 home_profile: CodexHomeProfile,
20903 ) -> (
20904 NodeShared,
20905 NativeRuntime,
20906 ResolvedEnvironmentProfileReceipt,
20907 NodeBundle,
20908 PathBuf,
20909 ) {
20910 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20911 const CLAUDE_MANIFEST: &[u8] =
20912 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20913 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20914 const DIGEST: &str =
20915 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20916
20917 let (shared, mut runtime, _claude_receipt, root, _deny) =
20918 materialization_test_shared(false);
20919 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20920 CodexHomeProfile::Missing => "codex-home-missing",
20921 CodexHomeProfile::WrongClass => "codex-home-wrong",
20922 CodexHomeProfile::Exact => "codex-home-exact",
20923 })
20924 .unwrap();
20925 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20926 "codex-home-r1",
20927 )
20928 .unwrap();
20929 let materialization = match home_profile {
20930 CodexHomeProfile::Missing => None,
20931 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20932 Some(NodeSessionMaterializationProfile::new(
20933 Vec::new(),
20934 vec![NodeSessionPathBinding::new(
20935 "CODEX_HOME",
20936 if matches!(home_profile, CodexHomeProfile::Exact) {
20937 NodeSessionPathClass::ProviderHome
20938 } else {
20939 NodeSessionPathClass::Config
20940 },
20941 )
20942 .unwrap()],
20943 Vec::new(),
20944 )
20945 .unwrap())
20946 }
20947 };
20948 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20949 profile_id.clone(),
20950 profile_revision.clone(),
20951 agent("codex"),
20952 [NativeLaunchProfile::new(
20953 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20954 agent("codex"),
20955 TransportKind::Pty,
20956 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20957 Arc::new(EmptyEnvironmentResolver),
20958 )
20959 .unwrap()],
20960 materialization,
20961 )
20962 .unwrap();
20963 let (binding, native_profiles, materialization) = node_profile.into_parts();
20964 for native_profile in native_profiles {
20965 runtime.upsert_native_launch_profile(native_profile).unwrap();
20966 }
20967 shared
20968 .environment_profiles
20969 .write()
20970 .unwrap_or_else(|poisoned| poisoned.into_inner())
20971 .insert(binding.id.clone(), binding);
20972 if let Some(materialization) = materialization {
20973 shared
20974 .environment_materialization_profiles
20975 .write()
20976 .unwrap_or_else(|poisoned| poisoned.into_inner())
20977 .insert(profile_id.clone(), materialization);
20978 }
20979
20980 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20981 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20982 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20983 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20984 std::fs::write(
20985 bundle_source.join(".claude-plugin/plugin.json"),
20986 CLAUDE_MANIFEST,
20987 )
20988 .unwrap();
20989 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20990 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20991 let bundle = NodeBundle::new(
20992 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20993 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20994 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20995 &bundle_source,
20996 )
20997 .unwrap();
20998 *shared
20999 .bundle_catalog
21000 .write()
21001 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21002 BundleCatalog::new([bundle.clone()]).unwrap();
21003 (
21004 shared,
21005 runtime,
21006 ResolvedEnvironmentProfileReceipt {
21007 profile_id,
21008 profile_revision,
21009 network_allowlist: None,
21010 browser_profile_id: None,
21011 },
21012 bundle,
21013 root,
21014 )
21015 }
21016
21017 #[cfg(feature = "fixture")]
21018 #[test]
21019 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
21020 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
21021 let (shared, runtime, receipt, bundle, root) =
21022 codex_bundle_test_shared(home_profile);
21023 let error = shared
21024 .bundle_layout(
21025 &agent("codex"),
21026 SessionMode::Pty,
21027 Some(&receipt),
21028 &bundle,
21029 )
21030 .unwrap_err();
21031 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21032 assert!(shared
21033 .materializations
21034 .lock()
21035 .unwrap_or_else(|poisoned| poisoned.into_inner())
21036 .is_empty());
21037 let materialization_root = root.join("session-environments");
21038 assert!(!std::fs::read_dir(&materialization_root)
21039 .unwrap()
21040 .filter_map(Result::ok)
21041 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21042 drop(shared);
21043 drop(runtime);
21044 let _ = std::fs::remove_dir_all(root);
21045 }
21046 }
21047
21048 #[cfg(feature = "fixture")]
21049 #[test]
21050 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21051 let (shared, runtime, receipt, bundle, root) =
21052 codex_bundle_test_shared(CodexHomeProfile::Exact);
21053 let address = SessionAddress {
21054 workspace_id: WorkspaceId::new("primary").unwrap(),
21055 session: SessionKey {
21056 instance_id: AgentInstanceId(310),
21057 generation: SessionGeneration(1),
21058 },
21059 };
21060 let (overlay, guard) = shared
21061 .prepare_session_materialization(
21062 &address,
21063 &agent("codex"),
21064 SessionMode::Pty,
21065 Some(&receipt),
21066 Some(&bundle.receipt()),
21067 None,
21068 None,
21069 )
21070 .unwrap()
21071 .unwrap();
21072 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21073 let ownership = shared
21074 .materializations
21075 .lock()
21076 .unwrap_or_else(|poisoned| poisoned.into_inner())
21077 .get(guard.id().unwrap())
21078 .unwrap()
21079 .clone();
21080 let (profile, _, _) = shared
21081 .materialization_profile(
21082 &agent("codex"),
21083 SessionMode::Pty,
21084 Some(&receipt),
21085 Some(&bundle.receipt()),
21086 None,
21087 )
21088 .unwrap()
21089 .unwrap();
21090 let environment = shared
21091 .session_environment_materializer
21092 .as_ref()
21093 .unwrap()
21094 .resolve_environment(&ownership, &profile)
21095 .unwrap();
21096 assert!(environment.iter().any(|mutation| {
21097 mutation.key == OsString::from("CODEX_HOME")
21098 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21099 }));
21100 assert_eq!(
21101 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21102 .unwrap(),
21103 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21104 );
21105 assert!(std::fs::read_dir(ownership.bundle_root())
21106 .unwrap()
21107 .next()
21108 .is_none());
21109 drop(guard);
21110 drop(shared);
21111 drop(runtime);
21112 let _ = std::fs::remove_dir_all(root);
21113 }
21114
21115 #[cfg(feature = "fixture")]
21116 #[test]
21117 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21118 let (shared, runtime, receipt, bundle, root) =
21119 codex_bundle_test_shared(CodexHomeProfile::Exact);
21120 let bundle_receipt = bundle.receipt();
21121 let address = SessionAddress {
21122 workspace_id: WorkspaceId::new("primary").unwrap(),
21123 session: SessionKey {
21124 instance_id: AgentInstanceId(311),
21125 generation: SessionGeneration(1),
21126 },
21127 };
21128 let (_overlay, guard) = shared
21129 .prepare_session_materialization(
21130 &address,
21131 &agent("codex"),
21132 SessionMode::Pty,
21133 Some(&receipt),
21134 Some(&bundle_receipt),
21135 None,
21136 None,
21137 )
21138 .unwrap()
21139 .unwrap();
21140 let materialization_id = guard.id().unwrap().clone();
21141 let skill_path = shared
21142 .materializations
21143 .lock()
21144 .unwrap_or_else(|poisoned| poisoned.into_inner())
21145 .get(&materialization_id)
21146 .unwrap()
21147 .provider_home()
21148 .join("skills/review-code/SKILL.md");
21149 let record_id = shared
21150 .bind_spawn_session_with_materialization(
21151 &address,
21152 agent("codex"),
21153 SessionMode::Pty,
21154 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21155 SpawnRecordPolicy::ProviderIdentityOnly,
21156 Some(receipt.clone()),
21157 Some(bundle_receipt.clone()),
21158 None,
21159 Some(materialization_id.clone()),
21160 )
21161 .unwrap()
21162 .unwrap();
21163 guard.retain();
21164 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21165
21166 let error = match shared.resolve_record_materialization(
21167 &record_id,
21168 &agent("codex"),
21169 SessionMode::Pty,
21170 Some(&receipt),
21171 Some(&bundle_receipt),
21172 None,
21173 ) {
21174 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21175 Err(error) => error,
21176 };
21177 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21178 assert_eq!(
21179 shared
21180 .materializations
21181 .lock()
21182 .unwrap_or_else(|poisoned| poisoned.into_inner())
21183 .get(&materialization_id)
21184 .unwrap()
21185 .state(),
21186 MaterializationState::RecoveryRequired,
21187 );
21188 drop(shared);
21189 drop(runtime);
21190 let _ = std::fs::remove_dir_all(root);
21191 }
21192
21193 fn bind_record_owned_materialization_fixture(
21194 shared: &NodeShared,
21195 receipt: &ResolvedEnvironmentProfileReceipt,
21196 instance_id: u64,
21197 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21198 let address = SessionAddress {
21199 workspace_id: WorkspaceId::new("primary").unwrap(),
21200 session: SessionKey {
21201 instance_id: AgentInstanceId(instance_id),
21202 generation: SessionGeneration(1),
21203 },
21204 };
21205 let (overlay, materialization_guard) = shared
21206 .prepare_session_materialization(
21207 &address,
21208 &agent("claude"),
21209 SessionMode::Pty,
21210 Some(receipt),
21211 None,
21212 None,
21213 None,
21214 )
21215 .unwrap()
21216 .unwrap();
21217 let materialization_id = materialization_guard.id().unwrap().clone();
21218 let materialization_root = shared
21219 .materializations
21220 .lock()
21221 .unwrap_or_else(|poisoned| poisoned.into_inner())
21222 .get(&materialization_id)
21223 .unwrap()
21224 .root()
21225 .to_path_buf();
21226 let selection = shared
21227 .select_environment_profile(
21228 address.session.instance_id,
21229 &agent("claude"),
21230 SessionMode::Pty,
21231 Some(receipt),
21232 )
21233 .unwrap()
21234 .unwrap();
21235 assert!(shared
21236 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21237 .unwrap()
21238 .is_none());
21239 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21240 let record_id = shared
21241 .bind_spawn_session_with_materialization(
21242 &address,
21243 agent("claude"),
21244 SessionMode::Pty,
21245 policy,
21246 SpawnRecordPolicy::ProviderIdentityOnly,
21247 Some(receipt.clone()),
21248 None,
21249 None,
21250 Some(materialization_id.clone()),
21251 )
21252 .unwrap()
21253 .unwrap();
21254 selection.retain();
21255 materialization_guard.retain();
21256 (address, record_id, materialization_id, materialization_root)
21257 }
21258
21259 #[test]
21260 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21261 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21262 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21263 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21264 }));
21265 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21266 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21267 let mut spec = spawn_spec_fixture();
21268 spec.expected_profile_revision =
21269 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21270 assert!(matches!(
21271 &spec.overrides.environment_profile_id,
21272 crate::protocol::SpawnOverride::Inherit,
21273 ));
21274 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21275 .requires_child_environment_profile_capability());
21276 assert!(request_requires_child_environment_profile(
21277 &shared,
21278 &NodeRequest::SpawnSpec { spec: spec.clone() },
21279 ));
21280
21281 let address = terminal_address(1);
21282 shared.bind_session_with_policy(
21283 &address,
21284 ProviderRuntimePolicy::raw_pty(),
21285 Some(environment_profile.clone()),
21286 );
21287 assert!(request_requires_child_environment_profile(
21288 &shared,
21289 &NodeRequest::Input {
21290 session: address.clone(),
21291 text: "x".to_owned(),
21292 },
21293 ));
21294
21295 let mut record = record("claude", "profile-record");
21296 record.active_session = Some(address.clone());
21297 record.environment_profile = Some(environment_profile.clone());
21298 let record_id = record.record_id.clone();
21299 shared.insert_record(record.clone()).unwrap();
21300 assert!(request_requires_child_environment_profile(
21301 &shared,
21302 &NodeRequest::ResumeSessionRecord {
21303 record_id,
21304 terminal_size: gate4agent_types::TerminalSize {
21305 rows: 24,
21306 columns: 80,
21307 },
21308 initial_prompt: None,
21309 },
21310 ));
21311
21312 let snapshot = snapshot_for_wire(
21313 &shared, false, true, true, true, false, false, false, false, true,
21314 );
21315 assert_eq!(snapshot.session_records.len(), 1);
21316 assert!(snapshot.session_records[0].environment_profile.is_none());
21317 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21318 sequence: 1,
21319 event: NodeEvent::SessionRecordUpserted { record },
21320 });
21321 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21322 panic!("record projection changed the event kind");
21323 };
21324 assert!(record.environment_profile.is_none());
21325
21326 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21327 let mut reply = ResponseEnvelope {
21328 request_id: 1,
21329 result: Ok(NodeResponse::SpawnSpecAccepted {
21330 receipt: resolved.receipt_with_environment(
21331 shared.incarnation_id,
21332 address,
21333 Some(environment_profile),
21334 ),
21335 }),
21336 };
21337 project_response_without_child_environment_profile(&mut reply);
21338 assert_eq!(
21339 reply.result.unwrap_err().code,
21340 NodeFailureCode::UnsupportedCapability,
21341 );
21342 }
21343
21344 #[test]
21345 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21346 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21347 let snapshot = shared.snapshot();
21348 let profiles = snapshot
21349 .launch_inventory
21350 .as_ref()
21351 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21352 .unwrap();
21353 assert_eq!(profiles.len(), 1);
21354 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21355 assert!(snapshot.requires_child_environment_profile_capability());
21356
21357 let mut legacy = snapshot.clone();
21358 clear_snapshot_child_environment_profiles(&mut legacy);
21359 assert!(legacy
21360 .launch_inventory
21361 .as_ref()
21362 .unwrap()
21363 .spawn_profiles
21364 .as_ref()
21365 .unwrap()[0]
21366 .environment_profile
21367 .is_none());
21368 assert!(!legacy.requires_child_environment_profile_capability());
21369
21370 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21371 let bindings = shared
21372 .environment_profiles
21373 .read()
21374 .unwrap_or_else(|poisoned| poisoned.into_inner())
21375 .clone();
21376 let mut provider_mismatch = bindings.clone();
21377 provider_mismatch
21378 .values_mut()
21379 .next()
21380 .unwrap()
21381 .provider = agent("codex");
21382 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21383 let mut mode_mismatch = profile;
21384 mode_mismatch.mode = SessionMode::Inline;
21385 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21386
21387 shared
21388 .environment_profiles
21389 .write()
21390 .unwrap_or_else(|poisoned| poisoned.into_inner())
21391 .clear();
21392 assert!(shared
21393 .snapshot()
21394 .launch_inventory
21395 .unwrap()
21396 .spawn_profiles
21397 .unwrap()
21398 .is_empty());
21399 }
21400
21401 #[test]
21402 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21403 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21404 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21405 }));
21406 let shared = terminal_test_shared();
21407 let bundle = ResolvedBundleReceipt {
21408 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21409 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21410 digest: crate::protocol::SpawnBundleDigest::new(format!(
21411 "sha256:{}",
21412 "0".repeat(64),
21413 ))
21414 .unwrap(),
21415 };
21416 let mut bundled = record("claude", "bundle-record");
21417 bundled.bundle = Some(bundle.clone());
21418 let record_id = bundled.record_id.clone();
21419 shared.insert_record(bundled.clone()).unwrap();
21420 assert!(request_requires_session_bundle(
21421 &shared,
21422 &NodeRequest::ForgetSessionRecord {
21423 record_id: record_id.clone(),
21424 },
21425 ));
21426
21427 let snapshot = snapshot_for_wire(
21428 &shared, false, true, true, true, true, false, false, false, true,
21429 );
21430 assert!(snapshot.session_records[0].bundle.is_none());
21431 let launch = snapshot.launch_inventory.as_ref().unwrap();
21432 assert!(launch.spawn_profiles.is_some());
21433 assert!(launch.bundles.is_none());
21434 let event = project_event_without_session_bundle(NodeEventEnvelope {
21435 sequence: 1,
21436 event: NodeEvent::SessionRecordUpserted { record: bundled },
21437 });
21438 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21439 panic!("record projection changed the event kind");
21440 };
21441 assert!(record.bundle.is_none());
21442
21443 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21444 let mut reply = ResponseEnvelope {
21445 request_id: 1,
21446 result: Ok(NodeResponse::SpawnSpecAccepted {
21447 receipt: resolved.receipt_with_materialization(
21448 shared.incarnation_id,
21449 terminal_address(17),
21450 None,
21451 Some(bundle),
21452 None,
21453 ),
21454 }),
21455 };
21456 project_response_without_session_bundle(&mut reply);
21457 assert_eq!(
21458 reply.result.unwrap_err().code,
21459 NodeFailureCode::UnsupportedCapability,
21460 );
21461 }
21462
21463 #[test]
21464 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21465 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21466 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21467 }));
21468 let context_id = SpawnContextId::new("context-review").unwrap();
21469 let context = ResolvedContextPackReceipt {
21470 id: context_id.clone(),
21471 digest: crate::protocol::SpawnContextDigest::new(format!(
21472 "sha256:{}",
21473 "c".repeat(64),
21474 ))
21475 .unwrap(),
21476 lineage: ContextPackLineageReceipt {
21477 source_node_id: NodeId::new("node-source").unwrap(),
21478 source_session: terminal_address(1),
21479 source_provider: agent("kimi"),
21480 },
21481 source_message_count: 2,
21482 retained_message_count: 2,
21483 byte_len: 256,
21484 truncated: false,
21485 };
21486 assert!(context.is_valid());
21487 let mut bound_record = record("codex", "context-record");
21488 bound_record.context_id = Some(context_id);
21489 bound_record.context = Some(context.clone());
21490 let session = gate4agent_types::SessionSnapshot {
21491 instance_id: AgentInstanceId(91),
21492 agent_id: agent("kimi"),
21493 transport: TransportKind::Pty,
21494 generation: SessionGeneration(3),
21495 status: SessionStatus::Running,
21496 pending_operation: None,
21497 pending_input: None,
21498 process_id: Some(9001),
21499 terminal_size: None,
21500 terminal_frame: None,
21501 terminal_stale: None,
21502 session_options: None,
21503 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21504 history: gate4agent_types::HistorySnapshot {
21505 pending: None,
21506 candidates: vec![HistoryCandidateSummary {
21507 id: "candidate-opaque".to_owned(),
21508 session_id_hint: "session-hint".to_owned(),
21509 modified_at_unix_ms: Some(1),
21510 }],
21511 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21512 loaded: Some(HistorySessionRecord {
21513 session_id: "private-vendor-session".to_owned(),
21514 title: Some("private title".to_owned()),
21515 cwd: Some(r"C:\private\history-root".to_owned()),
21516 model: Some("private-model".to_owned()),
21517 message_count: 2,
21518 completed_turn_count: None,
21519 total_tokens: 19,
21520 messages: vec![
21521 gate4agent_types::HistoryMessageRecord {
21522 role: gate4agent_types::HistoryMessageRole::User,
21523 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21524 },
21525 gate4agent_types::HistoryMessageRecord {
21526 role: gate4agent_types::HistoryMessageRole::Assistant,
21527 text: "private answer".to_owned(),
21528 },
21529 ],
21530 }),
21531 last_error: None,
21532 },
21533 resume: gate4agent_types::ResumeSnapshot::default(),
21534 foreground: gate4agent_types::ForegroundSnapshot::default(),
21535 provider: gate4agent_types::ProviderSnapshot::default(),
21536 screen_state: gate4agent_types::PtyScreenState::default(),
21537 };
21538 let original = NodeSnapshot {
21539 node_id: NodeId::new("node-terminal-test").unwrap(),
21540 enabled_providers: vec![agent("kimi"), agent("codex")],
21541 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21542 workspaces: vec![WorkspaceSnapshot {
21543 workspace_id: WorkspaceId::new("primary").unwrap(),
21544 canonical_root: opaque_windows_path(
21545 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21546 ),
21547 sessions: vec![session],
21548 worktree_service_mode: None,
21549 managed_worktree_profiles: None,
21550 }],
21551 session_records: vec![bound_record],
21552 managed_worktrees: Vec::new(),
21553 launch_inventory: None,
21554 agent_progress: Vec::new(),
21555 };
21556
21557 let mut negotiated = original.clone();
21558 project_snapshot_history_for_wire(&mut negotiated, true);
21559 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21560 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21561 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21562 assert!(!serde_json::to_string(&negotiated)
21563 .unwrap()
21564 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21565
21566 let mut legacy = original;
21567 project_snapshot_history_for_wire(&mut legacy, false);
21568 clear_snapshot_context_packs(&mut legacy);
21569 assert_eq!(
21570 legacy.workspaces[0].sessions[0].history,
21571 gate4agent_types::HistorySnapshot::default(),
21572 );
21573 assert!(legacy.session_records[0].context_id.is_none());
21574 assert!(legacy.session_records[0].context.is_none());
21575 let encoded = serde_json::to_string(&legacy).unwrap();
21576 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21577 assert!(!encoded.contains("context-review"));
21578
21579 let mut reply = ResponseEnvelope {
21580 request_id: 7,
21581 result: Ok(NodeResponse::ContextPackExported { context }),
21582 };
21583 project_response_without_context_pack(&mut reply);
21584 assert_eq!(
21585 reply.result.unwrap_err().code,
21586 NodeFailureCode::UnsupportedCapability,
21587 );
21588 }
21589
21590 #[test]
21591 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21592 let shared = terminal_test_shared();
21593 let history = HistorySessionRecord {
21594 session_id: "fixture-history".to_owned(),
21595 title: None,
21596 cwd: None,
21597 model: None,
21598 message_count: 1,
21599 completed_turn_count: None,
21600 total_tokens: 0,
21601 messages: vec![gate4agent_types::HistoryMessageRecord {
21602 role: gate4agent_types::HistoryMessageRole::User,
21603 text: "bounded handoff".to_owned(),
21604 }],
21605 };
21606 let pack = NodeContextPack::export(
21607 ContextPackLineageReceipt {
21608 source_node_id: shared.node_id.clone(),
21609 source_session: terminal_address(1),
21610 source_provider: agent("claude"),
21611 },
21612 &history,
21613 )
21614 .unwrap();
21615 let context = shared
21616 .context_catalog
21617 .write()
21618 .unwrap_or_else(|poisoned| poisoned.into_inner())
21619 .insert(pack)
21620 .unwrap();
21621 let mut retained = record("codex", "context-retained-record");
21622 retained.context_id = Some(context.id.clone());
21623 retained.context = Some(context.clone());
21624 let record_id = retained.record_id.clone();
21625 shared.insert_record(retained).unwrap();
21626 assert_eq!(
21627 shared.forget_context_pack(&context.id).unwrap_err().code,
21628 NodeFailureCode::ContextPackBusy,
21629 );
21630 assert!(shared
21631 .context_catalog
21632 .read()
21633 .unwrap_or_else(|poisoned| poisoned.into_inner())
21634 .get(&context.id)
21635 .is_some());
21636 shared
21637 .session_records
21638 .lock()
21639 .unwrap_or_else(|poisoned| poisoned.into_inner())
21640 .records
21641 .remove(&record_id);
21642
21643 let mut spec = spawn_spec_fixture();
21644 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21645 value: context.id.clone(),
21646 };
21647 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21648 let receipt = resolved.receipt_with_materialization(
21649 shared.incarnation_id,
21650 terminal_address(2),
21651 None,
21652 None,
21653 Some(context.clone()),
21654 );
21655 assert!(receipt.context_binding_is_valid());
21656 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21657
21658 shared.forget_context_pack(&context.id).unwrap();
21659 assert!(shared
21660 .context_catalog
21661 .read()
21662 .unwrap_or_else(|poisoned| poisoned.into_inner())
21663 .get(&context.id)
21664 .is_none());
21665 assert!(shared
21666 .replay_spawn_spec(&spec, Instant::now())
21667 .unwrap()
21668 .is_none());
21669 }
21670
21671 #[test]
21672 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21673 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21674 let address = terminal_address(1);
21675 let selection = shared
21676 .select_environment_profile(
21677 address.session.instance_id,
21678 &agent("claude"),
21679 SessionMode::Pty,
21680 Some(&environment_profile),
21681 )
21682 .unwrap()
21683 .unwrap();
21684 shared.bind_session_with_policy(
21685 &address,
21686 ProviderRuntimePolicy::raw_pty(),
21687 Some(environment_profile.clone()),
21688 );
21689 selection.retain();
21690 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21691 assert!(matches!(
21692 runtime.remove_native_launch_profile(&native_id),
21693 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21694 ));
21695 assert!(shared.remove_binding(&address).is_some());
21696 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21697
21698 let mismatched = ResolvedEnvironmentProfileReceipt {
21699 profile_id: environment_profile.profile_id,
21700 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21701 "local-claude-r2",
21702 )
21703 .unwrap(),
21704 network_allowlist: None,
21705 browser_profile_id: None,
21706 };
21707 let error = match shared.select_environment_profile(
21708 AgentInstanceId(42),
21709 &agent("claude"),
21710 SessionMode::Pty,
21711 Some(&mismatched),
21712 ) {
21713 Ok(_) => panic!("mismatched environment profile revision was selected"),
21714 Err(error) => error,
21715 };
21716 assert_eq!(
21717 error.code,
21718 NodeFailureCode::EnvironmentProfileBindingMismatch,
21719 );
21720 assert!(!shared
21721 .session_bindings
21722 .lock()
21723 .unwrap_or_else(|poisoned| poisoned.into_inner())
21724 .contains_key(&AgentInstanceId(42)));
21725 }
21726
21727 #[test]
21728 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21729 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21730 let address = terminal_address(1);
21731 let selection = shared
21732 .select_environment_profile(
21733 address.session.instance_id,
21734 &agent("claude"),
21735 SessionMode::Pty,
21736 Some(&environment_profile),
21737 )
21738 .unwrap()
21739 .unwrap();
21740 let overlay = NativeInstanceLaunchOverlay::new(
21741 agent("claude"),
21742 TransportKind::Pty,
21743 vec![EnvMutation {
21744 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21745 value: Some(OsString::from("fixture-secret")),
21746 }],
21747 vec![
21748 OsString::from("--plugin-dir"),
21749 std::env::current_dir().unwrap().into_os_string(),
21750 ],
21751 )
21752 .unwrap();
21753 let instance_overlay = shared
21754 .install_prepared_launch_overlay(
21755 address.session.instance_id,
21756 PreparedNativeLaunchOverlay::Instance(overlay),
21757 )
21758 .unwrap()
21759 .unwrap();
21760 let bundle = ResolvedBundleReceipt {
21761 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21762 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21763 digest: crate::protocol::SpawnBundleDigest::new(format!(
21764 "sha256:{}",
21765 "0".repeat(64),
21766 ))
21767 .unwrap(),
21768 };
21769 shared.bind_session_with_materialization(
21770 &address,
21771 ProviderRuntimePolicy::raw_pty(),
21772 Some(environment_profile),
21773 Some(bundle),
21774 None,
21775 None,
21776 );
21777 selection.retain();
21778 instance_overlay.retain();
21779
21780 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21781 assert!(matches!(
21782 runtime.remove_native_launch_profile(&native_id),
21783 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21784 ));
21785 assert!(shared.remove_binding(&address).is_some());
21786 assert!(!shared
21787 .native_launch_profile_control
21788 .as_ref()
21789 .unwrap()
21790 .clear_native_instance_launch_overlay(address.session.instance_id));
21791 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21792 }
21793
21794 #[test]
21795 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21796 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21797 let address = SessionAddress {
21798 workspace_id: WorkspaceId::new("primary").unwrap(),
21799 session: SessionKey {
21800 instance_id: AgentInstanceId(201),
21801 generation: SessionGeneration(1),
21802 },
21803 };
21804 let error = match shared.prepare_session_materialization(
21805 &address,
21806 &agent("claude"),
21807 SessionMode::Pty,
21808 Some(&receipt),
21809 None,
21810 None,
21811 None,
21812 ) {
21813 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21814 Err(error) => error,
21815 };
21816 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21817 assert!(shared.handle.snapshot().sessions.is_empty());
21818 assert!(shared
21819 .session_bindings
21820 .lock()
21821 .unwrap_or_else(|poisoned| poisoned.into_inner())
21822 .is_empty());
21823 assert!(shared
21824 .materializations
21825 .lock()
21826 .unwrap_or_else(|poisoned| poisoned.into_inner())
21827 .is_empty());
21828 drop(shared);
21829 drop(runtime);
21830 std::fs::remove_dir_all(root).unwrap();
21831 }
21832
21833 #[test]
21834 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21835 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21836 let address = SessionAddress {
21837 workspace_id: WorkspaceId::new("primary").unwrap(),
21838 session: SessionKey {
21839 instance_id: AgentInstanceId(202),
21840 generation: SessionGeneration(1),
21841 },
21842 };
21843 let (overlay, materialization_guard) = shared
21844 .prepare_session_materialization(
21845 &address,
21846 &agent("claude"),
21847 SessionMode::Pty,
21848 Some(&receipt),
21849 None,
21850 None,
21851 None,
21852 )
21853 .unwrap()
21854 .unwrap();
21855 let materialization_id = materialization_guard.id().unwrap().clone();
21856 let materialization_root = shared
21857 .materializations
21858 .lock()
21859 .unwrap_or_else(|poisoned| poisoned.into_inner())
21860 .get(&materialization_id)
21861 .unwrap()
21862 .root()
21863 .to_path_buf();
21864 let selection = shared
21865 .select_environment_profile(
21866 address.session.instance_id,
21867 &agent("claude"),
21868 SessionMode::Pty,
21869 Some(&receipt),
21870 )
21871 .unwrap()
21872 .unwrap();
21873 assert!(shared
21874 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21875 .unwrap()
21876 .is_none());
21877 shared.bind_session_with_materialization(
21878 &address,
21879 ProviderRuntimePolicy::raw_pty(),
21880 Some(receipt),
21881 None,
21882 None,
21883 Some(materialization_id),
21884 );
21885 selection.retain();
21886 materialization_guard.retain();
21887
21888 assert!(materialization_root.is_dir());
21889 assert!(shared
21890 .session_bindings
21891 .lock()
21892 .unwrap_or_else(|poisoned| poisoned.into_inner())
21893 .contains_key(&address.session.instance_id));
21894
21895 let removed = shared.remove_binding(&address).unwrap();
21896 shared
21897 .cleanup_session_owned_materialization(&address, &removed)
21898 .unwrap();
21899 assert!(!materialization_root.exists());
21900 assert!(shared
21901 .materializations
21902 .lock()
21903 .unwrap_or_else(|poisoned| poisoned.into_inner())
21904 .is_empty());
21905 drop(shared);
21906 drop(runtime);
21907 std::fs::remove_dir_all(root).unwrap();
21908 }
21909
21910 #[tokio::test]
21911 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21912 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21913 let address = SessionAddress {
21914 workspace_id: WorkspaceId::new("primary").unwrap(),
21915 session: SessionKey {
21916 instance_id: AgentInstanceId(203),
21917 generation: SessionGeneration(1),
21918 },
21919 };
21920 let (overlay, materialization_guard) = shared
21921 .prepare_session_materialization(
21922 &address,
21923 &agent("claude"),
21924 SessionMode::Pty,
21925 Some(&receipt),
21926 None,
21927 None,
21928 None,
21929 )
21930 .unwrap()
21931 .unwrap();
21932 let materialization_id = materialization_guard.id().unwrap().clone();
21933 let materialization_root = shared
21934 .materializations
21935 .lock()
21936 .unwrap_or_else(|poisoned| poisoned.into_inner())
21937 .get(&materialization_id)
21938 .unwrap()
21939 .root()
21940 .to_path_buf();
21941 let selection = shared
21942 .select_environment_profile(
21943 address.session.instance_id,
21944 &agent("claude"),
21945 SessionMode::Pty,
21946 Some(&receipt),
21947 )
21948 .unwrap()
21949 .unwrap();
21950 assert!(shared
21951 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21952 .unwrap()
21953 .is_none());
21954 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21955 let record_id = shared
21956 .bind_spawn_session_with_materialization(
21957 &address,
21958 agent("claude"),
21959 SessionMode::Pty,
21960 policy,
21961 SpawnRecordPolicy::ProviderIdentityOnly,
21962 Some(receipt.clone()),
21963 None,
21964 None,
21965 Some(materialization_id.clone()),
21966 )
21967 .unwrap()
21968 .unwrap();
21969 selection.retain();
21970 materialization_guard.retain();
21971 assert_eq!(
21972 shared
21973 .materializations
21974 .lock()
21975 .unwrap_or_else(|poisoned| poisoned.into_inner())
21976 .get(&materialization_id)
21977 .unwrap()
21978 .owner(),
21979 &MaterializationOwner::Record {
21980 record_id: record_id.clone(),
21981 },
21982 );
21983
21984 let removed = shared.remove_binding(&address).unwrap();
21985 shared
21986 .cleanup_session_owned_materialization(&address, &removed)
21987 .unwrap();
21988 assert!(materialization_root.is_dir());
21989 assert!(shared
21990 .resolve_record_materialization(
21991 &record_id,
21992 &agent("claude"),
21993 SessionMode::Pty,
21994 Some(&receipt),
21995 None,
21996 None,
21997 )
21998 .unwrap()
21999 .is_some());
22000 {
22001 let mut records = shared
22002 .session_records
22003 .lock()
22004 .unwrap_or_else(|poisoned| poisoned.into_inner());
22005 let record = records.records.get_mut(&record_id).unwrap();
22006 record.active_session = None;
22007 record.state = ManagedSessionState::Unavailable;
22008 }
22009 shared.forget_session_record(&record_id).await.unwrap();
22010 assert!(!materialization_root.exists());
22011 assert!(shared
22012 .materializations
22013 .lock()
22014 .unwrap_or_else(|poisoned| poisoned.into_inner())
22015 .is_empty());
22016 drop(shared);
22017 drop(runtime);
22018 std::fs::remove_dir_all(root).unwrap();
22019 }
22020
22021 #[test]
22022 fn record_materialization_revalidation_failure_persists_recovery_required() {
22023 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22024 let state_path = root.join("node-state-v6.json");
22025 shared.state_path = Some(state_path.clone());
22026 let (_address, _record_id, materialization_id, materialization_root) =
22027 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22028 std::fs::write(
22029 materialization_root.join(".gate4agent-materialization-owner"),
22030 b"tampered",
22031 )
22032 .unwrap();
22033
22034 let error = match shared.resolve_record_materialization(
22035 &_record_id,
22036 &agent("claude"),
22037 SessionMode::Pty,
22038 Some(&receipt),
22039 None,
22040 None,
22041 ) {
22042 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22043 Err(error) => error,
22044 };
22045 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22046 assert_eq!(
22047 shared
22048 .materializations
22049 .lock()
22050 .unwrap_or_else(|poisoned| poisoned.into_inner())
22051 .get(&materialization_id)
22052 .unwrap()
22053 .state(),
22054 MaterializationState::RecoveryRequired,
22055 );
22056 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22057 assert_eq!(loaded.materializations.len(), 1);
22058 assert_eq!(
22059 loaded.materializations[0].state(),
22060 MaterializationState::RecoveryRequired,
22061 );
22062 drop(shared);
22063 drop(runtime);
22064 std::fs::remove_dir_all(root).unwrap();
22065 }
22066
22067 #[test]
22068 fn transient_record_materialization_resolution_failure_remains_ready() {
22069 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22070 let (_address, record_id, materialization_id, _materialization_root) =
22071 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22072 deny.store(true, Ordering::Release);
22073
22074 let error = match shared.resolve_record_materialization(
22075 &record_id,
22076 &agent("claude"),
22077 SessionMode::Pty,
22078 Some(&receipt),
22079 None,
22080 None,
22081 ) {
22082 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22083 Err(error) => error,
22084 };
22085 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22086 assert_eq!(
22087 shared
22088 .materializations
22089 .lock()
22090 .unwrap_or_else(|poisoned| poisoned.into_inner())
22091 .get(&materialization_id)
22092 .unwrap()
22093 .state(),
22094 MaterializationState::Ready,
22095 );
22096 drop(shared);
22097 drop(runtime);
22098 std::fs::remove_dir_all(root).unwrap();
22099 }
22100
22101 #[test]
22102 fn startup_missing_record_materialization_is_not_resumable() {
22103 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22104 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22105 shared
22106 .insert_record(ManagedSessionRecord {
22107 record_id: record_id.clone(),
22108 display_name: "missing materialization".to_owned(),
22109 provider: agent("claude"),
22110 mode: SessionMode::Pty,
22111 state: ManagedSessionState::Dormant,
22112 workspace_id: WorkspaceId::new("primary").unwrap(),
22113 canonical_root: opaque_windows_path(
22114 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22115 ),
22116 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22117 key: gate4agent_types::ProviderSessionKey::SessionId,
22118 id: "provider-session-missing-materialization".to_owned(),
22119 transcript_path: None,
22120 }),
22121 active_session: None,
22122 environment_profile: Some(receipt),
22123 bundle: None,
22124 context_id: None,
22125 context: None,
22126 exported_context: None,
22127 task_binding: None,
22128 created_at_unix_ms: 1,
22129 updated_at_unix_ms: 1,
22130 last_error: None,
22131 })
22132 .unwrap();
22133
22134 shared.reconcile_materializations();
22135 let record = shared.record(&record_id).unwrap();
22136 assert_eq!(record.state, ManagedSessionState::Unavailable);
22137 assert!(record.provider_session.is_none());
22138 assert_eq!(
22139 record.last_error.as_deref(),
22140 Some("environment-profile-unavailable"),
22141 );
22142 drop(shared);
22143 drop(runtime);
22144 std::fs::remove_dir_all(root).unwrap();
22145 }
22146
22147 #[tokio::test]
22148 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22149 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22150 let lease = install_managed_lease(&shared);
22151 let address = SessionAddress {
22152 workspace_id: WorkspaceId::new("primary").unwrap(),
22153 session: SessionKey {
22154 instance_id: AgentInstanceId(204),
22155 generation: SessionGeneration(1),
22156 },
22157 };
22158 let (overlay, materialization_guard) = shared
22159 .prepare_session_materialization(
22160 &address,
22161 &agent("claude"),
22162 SessionMode::Pty,
22163 Some(&receipt),
22164 None,
22165 None,
22166 Some(lease.lease_id.clone()),
22167 )
22168 .unwrap()
22169 .unwrap();
22170 let materialization_id = materialization_guard.id().unwrap().clone();
22171 let materialization_root = shared
22172 .materializations
22173 .lock()
22174 .unwrap_or_else(|poisoned| poisoned.into_inner())
22175 .get(&materialization_id)
22176 .unwrap()
22177 .root()
22178 .to_path_buf();
22179 let selection = shared
22180 .select_environment_profile(
22181 address.session.instance_id,
22182 &agent("claude"),
22183 SessionMode::Pty,
22184 Some(&receipt),
22185 )
22186 .unwrap()
22187 .unwrap();
22188 assert!(shared
22189 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22190 .unwrap()
22191 .is_none());
22192 shared.bind_session_with_materialization(
22193 &address,
22194 ProviderRuntimePolicy::raw_pty(),
22195 Some(receipt),
22196 None,
22197 None,
22198 Some(materialization_id),
22199 );
22200 selection.retain();
22201 materialization_guard.retain();
22202
22203 let blocked = shared
22204 .cleanup_managed_worktree(&lease.lease_id, false)
22205 .await
22206 .unwrap_err();
22207 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22208 assert!(materialization_root.is_dir());
22209 let removed = shared.remove_binding(&address).unwrap();
22210 shared
22211 .cleanup_session_owned_materialization(&address, &removed)
22212 .unwrap();
22213 assert!(!materialization_root.exists());
22214 drop(shared);
22215 drop(runtime);
22216 std::fs::remove_dir_all(root).unwrap();
22217 }
22218
22219 #[tokio::test]
22220 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22221 let git_root = temporary_workspace_root("raw-managed-inventory");
22222 let source = git_root.join("source");
22223 let allocation = git_root.join("allocation");
22224 std::fs::create_dir_all(&source).unwrap();
22225 std::fs::create_dir_all(&allocation).unwrap();
22226 run_test_git(&source, &["init"]);
22227 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22228 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22229 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22230 run_test_git(&source, &["add", "seed.txt"]);
22231 run_test_git(&source, &["commit", "-m", "seed"]);
22232 let profile = ManagedWorktreeProfile::new(
22233 WorktreeProfileId::new("default").unwrap(),
22234 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22235 &allocation,
22236 "gate4agent",
22237 "HEAD",
22238 ManagedWorktreeRetention::RemoveWhenReleased,
22239 )
22240 .unwrap();
22241 let workspace = WorkspaceConfig::new(
22242 WorkspaceId::new("primary").unwrap(),
22243 &source,
22244 )
22245 .unwrap()
22246 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22247 .with_managed_worktree_profile(profile.clone())
22248 .unwrap();
22249 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22250 materialization_test_shared_with_workspace(false, workspace);
22251 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22252 let source_text = source.to_string_lossy().into_owned();
22253 let target_text = target.to_string_lossy().into_owned();
22254 let created = create_git_worktree(
22255 &source_text,
22256 &target_text,
22257 "gate4agent/mw-raw-inventory",
22258 Some("HEAD"),
22259 )
22260 .await
22261 .unwrap();
22262 let lease = ManagedWorktreeLeaseRecord {
22263 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22264 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22265 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22266 profile_id: profile.profile_id().clone(),
22267 profile_revision: profile.revision().clone(),
22268 target_root: created.path.clone(),
22269 branch: "gate4agent/mw-raw-inventory".to_owned(),
22270 base_commit: created.head.clone(),
22271 expected_head: Some(created.head.clone()),
22272 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22273 state: ManagedWorktreeLeaseState::Ready,
22274 session_holders: Vec::new(),
22275 record_holders: Vec::new(),
22276 cleanup_failure: None,
22277 created_at_unix_ms: 1,
22278 updated_at_unix_ms: 1,
22279 };
22280 shared
22281 .workspaces
22282 .write()
22283 .unwrap_or_else(|poisoned| poisoned.into_inner())
22284 .insert(lease.workspace_id.clone(), created.path.clone());
22285 *shared
22286 .managed_worktrees
22287 .lock()
22288 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22289 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22290 let address = SessionAddress {
22291 workspace_id: lease.workspace_id.clone(),
22292 session: SessionKey {
22293 instance_id: AgentInstanceId(205),
22294 generation: SessionGeneration(1),
22295 },
22296 };
22297 let (overlay, materialization_guard) = shared
22298 .prepare_session_materialization(
22299 &address,
22300 &agent("claude"),
22301 SessionMode::Pty,
22302 Some(&receipt),
22303 None,
22304 None,
22305 Some(lease.lease_id.clone()),
22306 )
22307 .unwrap()
22308 .unwrap();
22309 let materialization_id = materialization_guard.id().unwrap().clone();
22310 let materialization_root = shared
22311 .materializations
22312 .lock()
22313 .unwrap_or_else(|poisoned| poisoned.into_inner())
22314 .get(&materialization_id)
22315 .unwrap()
22316 .root()
22317 .to_path_buf();
22318 let selection = shared
22319 .select_environment_profile(
22320 address.session.instance_id,
22321 &agent("claude"),
22322 SessionMode::Pty,
22323 Some(&receipt),
22324 )
22325 .unwrap()
22326 .unwrap();
22327 assert!(shared
22328 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22329 .unwrap()
22330 .is_none());
22331 let record_id = shared
22332 .bind_spawn_session_with_materialization(
22333 &address,
22334 agent("claude"),
22335 SessionMode::Pty,
22336 ProviderRuntimePolicy::raw_pty(),
22337 SpawnRecordPolicy::Always,
22338 Some(receipt.clone()),
22339 None,
22340 None,
22341 Some(materialization_id.clone()),
22342 )
22343 .unwrap()
22344 .unwrap();
22345 selection.retain();
22346 materialization_guard.retain();
22347 let record = shared.record(&record_id).unwrap();
22348 assert_eq!(record.state, ManagedSessionState::Live);
22349 assert!(record.provider_session.is_none());
22350 assert!(record.environment_profile.is_none());
22351 assert!(record.bundle.is_none());
22352 assert!(record.context_id.is_none());
22353 assert!(record.context.is_none());
22354 assert_eq!(record.active_session.as_ref(), Some(&address));
22355 assert_eq!(
22356 shared
22357 .materializations
22358 .lock()
22359 .unwrap_or_else(|poisoned| poisoned.into_inner())
22360 .get(&materialization_id)
22361 .unwrap()
22362 .owner(),
22363 &MaterializationOwner::Session {
22364 incarnation_id: shared.incarnation_id,
22365 instance_id: address.session.instance_id,
22366 generation: address.session.generation,
22367 },
22368 );
22369 {
22370 let mut bindings = shared
22371 .session_bindings
22372 .lock()
22373 .unwrap_or_else(|poisoned| poisoned.into_inner());
22374 bindings
22375 .get_mut(&address.session.instance_id)
22376 .unwrap()
22377 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22378 }
22379 let lease_snapshot = shared
22380 .managed_worktrees
22381 .lock()
22382 .unwrap_or_else(|poisoned| poisoned.into_inner())
22383 .bind_session(
22384 &lease.lease_id,
22385 ManagedWorktreeSessionHolder {
22386 incarnation_id: shared.incarnation_id,
22387 instance_id: address.session.instance_id,
22388 generation: address.session.generation,
22389 },
22390 None,
22391 unix_time_ms(),
22392 )
22393 .unwrap();
22394 assert_eq!(lease_snapshot.active_session_count, 1);
22395 assert_eq!(lease_snapshot.managed_record_count, 0);
22396 shared.persist_state().unwrap();
22397 assert!(shared
22398 .managed_worktrees
22399 .lock()
22400 .unwrap_or_else(|poisoned| poisoned.into_inner())
22401 .get(&lease.lease_id)
22402 .unwrap()
22403 .record_holders
22404 .is_empty());
22405 shared
22406 .handle
22407 .dispatch(shared.prepare_command(ControlCommand::Register {
22408 instance_id: address.session.instance_id,
22409 agent_id: agent("claude"),
22410 transport: TransportKind::Pty,
22411 }))
22412 .unwrap();
22413 runtime.tick().await;
22414 shared.reconcile_managed_record(
22415 &record_id,
22416 &address,
22417 &ControlEvent {
22418 sequence: 1,
22419 command_id: None,
22420 instance_id: address.session.instance_id,
22421 generation: address.session.generation,
22422 event: ControlEventKind::Removed,
22423 },
22424 false,
22425 false,
22426 );
22427 let remove = shared.remove_session(&address);
22428 let drive_runtime = async {
22429 runtime.tick().await;
22430 };
22431 let (removed, ()) = tokio::join!(remove, drive_runtime);
22432 removed.unwrap();
22433
22434 assert!(!materialization_root.exists());
22435 assert!(!Path::new(&created.path).exists());
22436 assert_eq!(
22437 shared
22438 .managed_worktrees
22439 .lock()
22440 .unwrap_or_else(|poisoned| poisoned.into_inner())
22441 .get(&lease.lease_id)
22442 .unwrap()
22443 .state,
22444 ManagedWorktreeLeaseState::Removed,
22445 );
22446 let renamed = shared
22447 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22448 .unwrap();
22449 assert_eq!(renamed.record_id, record_id);
22450 assert_eq!(renamed.display_name, "raw managed inventory");
22451 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22452 assert!(renamed.active_session.is_none());
22453
22454 drop(shared);
22455 drop(runtime);
22456 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22457 std::fs::remove_dir_all(git_root).unwrap();
22458 }
22459
22460 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22461 let root = std::env::current_dir().unwrap()
22462 .join("managed-test-target")
22463 .to_string_lossy()
22464 .into_owned();
22465 let lease = ManagedWorktreeLeaseRecord {
22466 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22467 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22468 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22469 profile_id: WorktreeProfileId::new("default").unwrap(),
22470 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22471 target_root: root.clone(),
22472 branch: "gate4agent/mw-test".to_owned(),
22473 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22474 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22475 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22476 state: ManagedWorktreeLeaseState::Ready,
22477 session_holders: Vec::new(),
22478 record_holders: Vec::new(),
22479 cleanup_failure: None,
22480 created_at_unix_ms: 1,
22481 updated_at_unix_ms: 1,
22482 };
22483 shared.workspaces.write()
22484 .unwrap_or_else(|poisoned| poisoned.into_inner())
22485 .insert(lease.workspace_id.clone(), root);
22486 *shared.managed_worktrees.lock()
22487 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22488 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22489 lease
22490 }
22491
22492 #[cfg(feature = "fixture")]
22493 #[test]
22494 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22495 let mut shared = terminal_test_shared();
22496 shared.fixture_semantic_hook_policy = true;
22497 let lease = install_managed_lease(&shared);
22498 let address = SessionAddress {
22499 workspace_id: lease.workspace_id.clone(),
22500 session: SessionKey {
22501 instance_id: AgentInstanceId(206),
22502 generation: SessionGeneration(1),
22503 },
22504 };
22505 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22506 let effective_runtime_policy =
22507 shared.effective_spawn_runtime_policy(pre_admission_policy);
22508 assert_ne!(effective_runtime_policy, pre_admission_policy);
22509 assert!(effective_runtime_policy.provider_session_identity);
22510
22511 let record_id = shared
22512 .bind_spawn_session(
22513 &address,
22514 agent("claude"),
22515 SessionMode::Pty,
22516 effective_runtime_policy,
22517 SpawnRecordPolicy::Always,
22518 None,
22519 )
22520 .unwrap()
22521 .unwrap();
22522 assert_eq!(
22523 shared.record(&record_id).unwrap().state,
22524 ManagedSessionState::IdentityPending,
22525 );
22526 let (record_holder, raw_inventory_record_id) = {
22527 let mut bindings = shared
22528 .session_bindings
22529 .lock()
22530 .unwrap_or_else(|poisoned| poisoned.into_inner());
22531 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22532 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22533 managed_spawn_record_ownership(binding, effective_runtime_policy)
22534 };
22535 assert_eq!(record_holder.as_ref(), Some(&record_id));
22536 assert!(raw_inventory_record_id.is_none());
22537 let snapshot = shared
22538 .managed_worktrees
22539 .lock()
22540 .unwrap_or_else(|poisoned| poisoned.into_inner())
22541 .bind_session(
22542 &lease.lease_id,
22543 ManagedWorktreeSessionHolder {
22544 incarnation_id: shared.incarnation_id,
22545 instance_id: address.session.instance_id,
22546 generation: address.session.generation,
22547 },
22548 record_holder,
22549 unix_time_ms(),
22550 )
22551 .unwrap();
22552 assert_eq!(snapshot.managed_record_count, 1);
22553 assert_eq!(
22554 shared
22555 .managed_worktrees
22556 .lock()
22557 .unwrap_or_else(|poisoned| poisoned.into_inner())
22558 .get(&lease.lease_id)
22559 .unwrap()
22560 .record_holders,
22561 vec![record_id.clone()],
22562 );
22563
22564 let identity = ProviderSessionIdentity {
22565 key: ProviderSessionKey::SessionId,
22566 id: "monitoring-fixture-managed-session".to_owned(),
22567 transcript_path: None,
22568 };
22569 shared.publish_control(ControlEvent {
22570 sequence: 1,
22571 command_id: None,
22572 instance_id: address.session.instance_id,
22573 generation: address.session.generation,
22574 event: ControlEventKind::ProviderEvent {
22575 sequence: 1,
22576 source: gate4agent_types::ProviderSource {
22577 family: AdapterFamily::Hook,
22578 binding: AdapterBinding::new(
22579 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22580 "fixture/v1",
22581 gate4agent_types::AdapterVerification::SyntheticFixture,
22582 )
22583 .unwrap(),
22584 },
22585 source_sequence: 1,
22586 event: ProviderEvent::SessionIdentityObserved {
22587 identity: identity.clone(),
22588 },
22589 },
22590 });
22591
22592 let live = shared.record(&record_id).unwrap();
22593 assert_eq!(live.state, ManagedSessionState::Live);
22594 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22595 assert_eq!(live.active_session.as_ref(), Some(&address));
22596 assert_eq!(
22597 shared
22598 .managed_worktrees
22599 .lock()
22600 .unwrap_or_else(|poisoned| poisoned.into_inner())
22601 .get(&lease.lease_id)
22602 .unwrap()
22603 .record_holders,
22604 vec![record_id],
22605 );
22606 }
22607
22608 fn managed_v2_test_shared(
22609 allocation_root: &Path,
22610 state_path: Option<PathBuf>,
22611 profile_revision: &str,
22612 ) -> NodeShared {
22613 let profile = ManagedWorktreeProfile::new(
22614 WorktreeProfileId::new("default").unwrap(),
22615 WorktreeProfileRevision::new(profile_revision).unwrap(),
22616 allocation_root,
22617 "gate4agent",
22618 "HEAD",
22619 ManagedWorktreeRetention::RemoveWhenReleased,
22620 )
22621 .unwrap();
22622 let workspace = WorkspaceConfig::new(
22623 WorkspaceId::new("primary").unwrap(),
22624 std::env::current_dir().unwrap(),
22625 )
22626 .unwrap()
22627 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22628 .with_managed_worktree_profile(profile)
22629 .unwrap();
22630 let catalog = active_registry().unwrap();
22631 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22632 let mut shared = NodeShared::new(
22633 handle,
22634 "fixture-token".to_owned(),
22635 NodeId::new("node-terminal-test").unwrap(),
22636 vec![workspace],
22637 vec![agent("claude")],
22638 );
22639 shared.state_path = state_path;
22640 shared
22641 }
22642
22643 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22644 let mut spawn_spec = spawn_spec_fixture();
22645 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22646 ManagedWorktreeSpawnRequestV2 {
22647 spawn_spec,
22648 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22649 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22650 }
22651 }
22652
22653 #[test]
22654 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22655 let shared = terminal_test_shared();
22656 let mut lease = install_managed_lease(&shared);
22657 let session = SessionAddress {
22658 workspace_id: lease.workspace_id.clone(),
22659 session: SessionKey {
22660 instance_id: AgentInstanceId(73),
22661 generation: SessionGeneration(4),
22662 },
22663 };
22664 lease.state = ManagedWorktreeLeaseState::InUse;
22665 lease.session_holders.push(ManagedWorktreeSessionHolder {
22666 incarnation_id: shared.incarnation_id,
22667 instance_id: session.session.instance_id,
22668 generation: session.session.generation,
22669 });
22670 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22671 assert!(resolved.target.worktree_id.is_none());
22672
22673 let receipt = managed_spawn_receipt(
22674 &resolved,
22675 shared.incarnation_id,
22676 session.clone(),
22677 lease.snapshot(),
22678 None,
22679 None,
22680 None,
22681 );
22682 assert!(resolved.target.worktree_id.is_none());
22683 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22684 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22685 assert_eq!(receipt.spawn.session, session);
22686 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22687 let encoded = serde_json::to_vec(&receipt).unwrap();
22688 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22689 assert_eq!(decoded, receipt);
22690 }
22691
22692 #[tokio::test]
22693 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22694 let root = temporary_workspace_root("managed-v2-revision");
22695 let allocation = root.join("allocation");
22696 std::fs::create_dir_all(&allocation).unwrap();
22697 let shared = managed_v2_test_shared(
22698 &allocation,
22699 Some(root.join("node-state.json")),
22700 "v1",
22701 );
22702 let mut request = managed_v2_request("managed-v2-revision");
22703 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22704
22705 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22706
22707 assert_eq!(
22708 failure.code,
22709 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22710 );
22711 assert!(shared
22712 .managed_worktrees
22713 .lock()
22714 .unwrap_or_else(|poisoned| poisoned.into_inner())
22715 .records()
22716 .is_empty());
22717 assert!(shared
22718 .managed_spawn_replays
22719 .lock()
22720 .unwrap_or_else(|poisoned| poisoned.into_inner())
22721 .is_empty());
22722 std::fs::remove_dir_all(root).unwrap();
22723 }
22724
22725 #[test]
22726 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22727 let root = temporary_workspace_root("managed-v2-concurrent");
22728 let allocation = root.join("allocation");
22729 std::fs::create_dir_all(&allocation).unwrap();
22730 let shared = Arc::new(managed_v2_test_shared(
22731 &allocation,
22732 Some(root.join("node-state.json")),
22733 "v1",
22734 ));
22735 let request = managed_v2_request("managed-v2-concurrent");
22736 let barrier = Arc::new(std::sync::Barrier::new(3));
22737 let mut joins = Vec::new();
22738 for _ in 0..2 {
22739 let shared = Arc::clone(&shared);
22740 let request = request.clone();
22741 let barrier = Arc::clone(&barrier);
22742 joins.push(std::thread::spawn(move || {
22743 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22744 barrier.wait();
22745 shared.acquire_durable_managed_spawn_v2(&request, digest)
22746 }));
22747 }
22748 barrier.wait();
22749 let results = joins
22750 .into_iter()
22751 .map(|join| join.join().unwrap())
22752 .collect::<Vec<_>>();
22753 assert_eq!(
22754 results
22755 .iter()
22756 .filter(|result| {
22757 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22758 })
22759 .count(),
22760 1,
22761 );
22762 assert_eq!(
22763 results
22764 .iter()
22765 .filter(|result| {
22766 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22767 })
22768 .count(),
22769 1,
22770 );
22771 assert_eq!(
22772 shared
22773 .managed_spawn_replays
22774 .lock()
22775 .unwrap_or_else(|poisoned| poisoned.into_inner())
22776 .len(),
22777 1,
22778 );
22779 assert!(shared
22780 .managed_worktrees
22781 .lock()
22782 .unwrap_or_else(|poisoned| poisoned.into_inner())
22783 .records()
22784 .is_empty());
22785 drop(shared);
22786 std::fs::remove_dir_all(root).unwrap();
22787 }
22788
22789 #[tokio::test]
22790 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22791 let root = temporary_workspace_root("managed-v2-linked-pending");
22792 let allocation = root.join("allocation");
22793 std::fs::create_dir_all(&allocation).unwrap();
22794 let state_path = root.join("node-state.json");
22795 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22796 let lease = install_managed_lease(&shared);
22797 let request = managed_v2_request("managed-v2-linked-pending");
22798 assert!(matches!(
22799 shared
22800 .acquire_durable_managed_spawn_v2(
22801 &request,
22802 managed_spawn_request_digest_v2(&request).unwrap(),
22803 )
22804 .unwrap(),
22805 DurableManagedSpawnReplayDecision::Reserved,
22806 ));
22807 {
22808 let _transaction = shared
22809 .state_transaction
22810 .lock()
22811 .unwrap_or_else(|poisoned| poisoned.into_inner());
22812 shared
22813 .link_durable_managed_spawn_v2_lease_locked(
22814 &request.spawn_spec.idempotency_key,
22815 &lease.lease_id,
22816 )
22817 .unwrap();
22818 shared.persist_state_locked().unwrap();
22819 }
22820 drop(shared);
22821
22822 let mut loaded = session_registry::load(
22823 Some(&state_path),
22824 &NodeId::new("node-terminal-test").unwrap(),
22825 )
22826 .unwrap();
22827 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22828 *reopened
22829 .managed_worktrees
22830 .lock()
22831 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22832 ManagedWorktreeRegistry::from_records(
22833 std::mem::take(&mut loaded.managed_worktrees),
22834 std::mem::take(&mut loaded.managed_worktree_tombstones),
22835 )
22836 .unwrap();
22837 *reopened
22838 .managed_spawn_replays
22839 .lock()
22840 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22841 .managed_spawn_replays
22842 .into_iter()
22843 .map(|record| (record.idempotency_key.clone(), record))
22844 .collect();
22845 let lease_count = reopened
22846 .managed_worktrees
22847 .lock()
22848 .unwrap_or_else(|poisoned| poisoned.into_inner())
22849 .records()
22850 .len();
22851
22852 let failure = reopened
22853 .spawn_managed_worktree_v2(request)
22854 .await
22855 .unwrap_err();
22856
22857 assert_eq!(
22858 failure.code,
22859 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22860 );
22861 assert_eq!(
22862 reopened
22863 .managed_worktrees
22864 .lock()
22865 .unwrap_or_else(|poisoned| poisoned.into_inner())
22866 .records()
22867 .len(),
22868 lease_count,
22869 );
22870 std::fs::remove_dir_all(root).unwrap();
22871 }
22872
22873 #[tokio::test]
22874 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22875 let root = temporary_workspace_root("managed-v2-reopen");
22876 let allocation = root.join("allocation");
22877 std::fs::create_dir_all(&allocation).unwrap();
22878 let state_path = root.join("node-state.json");
22879 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22880 let lease = install_managed_lease(&shared);
22881 let request = managed_v2_request("managed-v2-reopen");
22882 let session = SessionAddress {
22883 workspace_id: lease.workspace_id.clone(),
22884 session: SessionKey {
22885 instance_id: AgentInstanceId(91),
22886 generation: SessionGeneration(1),
22887 },
22888 };
22889 let snapshot = {
22890 let mut registry = shared
22891 .managed_worktrees
22892 .lock()
22893 .unwrap_or_else(|poisoned| poisoned.into_inner());
22894 let current = registry.get_mut(&lease.lease_id).unwrap();
22895 current.state = ManagedWorktreeLeaseState::InUse;
22896 current.session_holders.push(ManagedWorktreeSessionHolder {
22897 incarnation_id: shared.incarnation_id,
22898 instance_id: session.session.instance_id,
22899 generation: session.session.generation,
22900 });
22901 current.snapshot()
22902 };
22903 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22904 let receipt = managed_spawn_receipt(
22905 &resolved,
22906 shared.incarnation_id,
22907 session,
22908 snapshot,
22909 None,
22910 None,
22911 None,
22912 );
22913 assert!(matches!(
22914 shared
22915 .acquire_durable_managed_spawn_v2(
22916 &request,
22917 managed_spawn_request_digest_v2(&request).unwrap(),
22918 )
22919 .unwrap(),
22920 DurableManagedSpawnReplayDecision::Reserved,
22921 ));
22922 {
22923 let _transaction = shared
22924 .state_transaction
22925 .lock()
22926 .unwrap_or_else(|poisoned| poisoned.into_inner());
22927 shared
22928 .link_durable_managed_spawn_v2_lease_locked(
22929 &request.spawn_spec.idempotency_key,
22930 &lease.lease_id,
22931 )
22932 .unwrap();
22933 shared.persist_state_locked().unwrap();
22934 }
22935 shared
22936 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22937 .unwrap();
22938 let legacy_same_key = ManagedWorktreeSpawnRequest {
22939 spawn_spec: request.spawn_spec.clone(),
22940 worktree_profile_id: request.worktree_profile_id.clone(),
22941 };
22942 shared.remember_managed_spawn_attempt(
22943 Some(&request.spawn_spec.idempotency_key),
22944 legacy_same_key.clone(),
22945 Ok(receipt.clone()),
22946 Instant::now(),
22947 );
22948 assert!(shared
22949 .replay_managed_spawn(&legacy_same_key, Instant::now())
22950 .unwrap()
22951 .is_none());
22952 drop(shared);
22953
22954 let mut loaded = session_registry::load(
22955 Some(&state_path),
22956 &NodeId::new("node-terminal-test").unwrap(),
22957 )
22958 .unwrap();
22959 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22960 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22961 *reopened
22962 .managed_worktrees
22963 .lock()
22964 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22965 ManagedWorktreeRegistry::from_records(
22966 std::mem::take(&mut loaded.managed_worktrees),
22967 std::mem::take(&mut loaded.managed_worktree_tombstones),
22968 )
22969 .unwrap();
22970 *reopened
22971 .managed_spawn_replays
22972 .lock()
22973 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22974 .managed_spawn_replays
22975 .into_iter()
22976 .map(|record| (record.idempotency_key.clone(), record))
22977 .collect();
22978 let lease_count_before = reopened
22979 .managed_worktrees
22980 .lock()
22981 .unwrap_or_else(|poisoned| poisoned.into_inner())
22982 .records()
22983 .len();
22984
22985 assert_eq!(
22986 reopened
22987 .spawn_managed_worktree_v2(request.clone())
22988 .await
22989 .unwrap(),
22990 receipt,
22991 );
22992 assert_eq!(
22993 reopened
22994 .managed_worktrees
22995 .lock()
22996 .unwrap_or_else(|poisoned| poisoned.into_inner())
22997 .records()
22998 .len(),
22999 lease_count_before,
23000 );
23001 let mut changed = request;
23002 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
23003 let failure = reopened
23004 .spawn_managed_worktree_v2(changed)
23005 .await
23006 .unwrap_err();
23007 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23008 assert_eq!(
23009 reopened
23010 .managed_worktrees
23011 .lock()
23012 .unwrap_or_else(|poisoned| poisoned.into_inner())
23013 .records()
23014 .len(),
23015 lease_count_before,
23016 );
23017 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
23018 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
23019 let failure = reopened
23020 .spawn_managed_worktree_v2(new_key)
23021 .await
23022 .unwrap_err();
23023 assert_eq!(
23024 failure.code,
23025 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23026 );
23027 assert_eq!(
23028 reopened
23029 .managed_worktrees
23030 .lock()
23031 .unwrap_or_else(|poisoned| poisoned.into_inner())
23032 .records()
23033 .len(),
23034 lease_count_before,
23035 );
23036 assert_eq!(
23037 reopened
23038 .managed_spawn_replays
23039 .lock()
23040 .unwrap_or_else(|poisoned| poisoned.into_inner())
23041 .len(),
23042 1,
23043 );
23044 std::fs::remove_dir_all(root).unwrap();
23045 }
23046
23047 #[test]
23048 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23049 let shared = terminal_test_shared();
23050 let request = ManagedWorktreeSpawnRequest {
23051 spawn_spec: spawn_spec_fixture(),
23052 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23053 };
23054 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23055 shared.remember_managed_spawn(
23056 request.clone(),
23057 Err(expected.clone()),
23058 Instant::now(),
23059 );
23060
23061 assert_eq!(
23062 shared
23063 .replay_managed_spawn(&request, Instant::now())
23064 .unwrap()
23065 .unwrap(),
23066 Err(expected),
23067 );
23068 assert!(shared
23069 .managed_spawn_replays
23070 .lock()
23071 .unwrap_or_else(|poisoned| poisoned.into_inner())
23072 .is_empty());
23073 }
23074
23075 #[tokio::test]
23076 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23077 let shared = terminal_test_shared();
23078 let lease = install_managed_lease(&shared);
23079 let raw = shared.spawn_session(
23080 lease.workspace_id.clone(),
23081 agent("claude"),
23082 SessionMode::Pty,
23083 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23084 None,
23085 ).await.unwrap_err();
23086 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23087 let mut spec = spawn_spec_fixture();
23088 spec.target.workspace_id = lease.workspace_id.clone();
23089 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23090 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23091 let mut selected = spawn_spec_fixture();
23092 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23093 selected.target.worktree_id = Some(lease.workspace_id);
23094 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23095 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23096 assert!(shared.handle.snapshot().sessions.is_empty());
23097 }
23098
23099 #[test]
23100 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23101 let shared = terminal_test_shared();
23102 let lease = install_managed_lease(&shared);
23103 let snapshot = snapshot_for_wire(
23104 &shared, true, true, true, false, true, true, false, false, true,
23105 );
23106 assert!(snapshot.managed_worktrees.is_empty());
23107 assert!(snapshot.workspaces.iter().all(|workspace| {
23108 workspace.managed_worktree_profiles.is_none()
23109 }));
23110 let mut reply = ResponseEnvelope {
23111 request_id: 1,
23112 result: Ok(NodeResponse::Resync {
23113 event_sequence: 1,
23114 oldest_available_sequence: 1,
23115 snapshot: shared.snapshot(),
23116 events: vec![NodeEventEnvelope {
23117 sequence: 1,
23118 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23119 }],
23120 }),
23121 };
23122 project_response_without_managed_worktrees(&mut reply);
23123 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23124 panic!("projection changed the response kind");
23125 };
23126 assert!(snapshot.managed_worktrees.is_empty());
23127 assert!(events.is_empty());
23128 let mut git = git_snapshot_for_parser();
23129 git.branch = Some(lease.branch.clone());
23130 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23131 lease_id: lease.lease_id.clone(),
23132 source_workspace_id: lease.source_workspace_id.clone(),
23133 branch: lease.branch.clone(),
23134 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23135 active_session_count: 1,
23136 managed_record_count: 0,
23137 });
23138 let mut inspection_reply = ResponseEnvelope {
23139 request_id: 2,
23140 result: Ok(NodeResponse::WorkspaceInspected {
23141 inspection: WorkspaceInspection {
23142 workspace_id: lease.workspace_id.clone(),
23143 entries: Vec::new(),
23144 tree_truncated: false,
23145 git,
23146 truncation: None,
23147 },
23148 }),
23149 };
23150 project_response_without_managed_worktrees(&mut inspection_reply);
23151 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23152 panic!("projection changed the inspection response kind");
23153 };
23154 assert!(inspection.git.managed_worktree.is_none());
23155 let failure = managed_git_worktree_failure(GitWorktreeError {
23156 kind: GitWorktreeErrorKind::Failed,
23157 message: r"git failed at C:\private\managed-target".to_owned(),
23158 });
23159 assert!(!failure.message.contains("private"));
23160 assert!(!failure.message.contains("managed-target"));
23161 }
23162
23163 #[tokio::test]
23164 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23165 let root = temporary_workspace_root("managed-restart");
23166 let source = root.join("source");
23167 let allocation = root.join("allocation");
23168 std::fs::create_dir_all(&source).unwrap();
23169 std::fs::create_dir_all(&allocation).unwrap();
23170 run_test_git(&source, &["init"]);
23171 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23172 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23173 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23174 run_test_git(&source, &["add", "seed.txt"]);
23175 run_test_git(&source, &["commit", "-m", "seed"]);
23176 let profile = ManagedWorktreeProfile::new(
23177 WorktreeProfileId::new("default").unwrap(),
23178 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23179 &allocation,
23180 "gate4agent",
23181 "HEAD",
23182 ManagedWorktreeRetention::Retain,
23183 ).unwrap();
23184 let workspace = WorkspaceConfig::new(
23185 WorkspaceId::new("primary").unwrap(),
23186 &source,
23187 ).unwrap()
23188 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23189 .with_managed_worktree_profile(profile.clone()).unwrap();
23190 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23191 let source_text = workspace.canonical_root().to_owned();
23192 let target_text = target.to_string_lossy().into_owned();
23193 let created = create_git_worktree(
23194 &source_text,
23195 &target_text,
23196 "gate4agent/mw-restart",
23197 Some("HEAD"),
23198 ).await.unwrap();
23199 let lease = ManagedWorktreeLeaseRecord {
23200 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23201 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23202 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23203 profile_id: profile.profile_id().clone(),
23204 profile_revision: profile.revision().clone(),
23205 target_root: created.path.clone(),
23206 branch: "gate4agent/mw-restart".to_owned(),
23207 base_commit: created.head.clone(),
23208 expected_head: None,
23209 retention: ManagedWorktreeRetention::Retain,
23210 state: ManagedWorktreeLeaseState::Allocating,
23211 session_holders: Vec::new(),
23212 record_holders: Vec::new(),
23213 cleanup_failure: None,
23214 created_at_unix_ms: 1,
23215 updated_at_unix_ms: 1,
23216 };
23217 let catalog = active_registry().unwrap();
23218 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23219 let shared = NodeShared::new_with_incarnation(
23220 handle,
23221 "fixture-token".to_owned(),
23222 NodeId::new("node-managed-restart").unwrap(),
23223 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23224 vec![workspace],
23225 vec![agent("claude")],
23226 ProviderRuntimeStatuses::default(),
23227 None,
23228 Vec::new(),
23229 Vec::new(),
23230 SpawnProfileRegistry::default(),
23231 None,
23232 None,
23233 None,
23234 None,
23235 Vec::new(),
23236 vec![lease.clone()],
23237 Vec::new(),
23238 Vec::new(),
23239 None,
23240 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23241 );
23242 shared.reconcile_managed_worktrees().await;
23243 let retained = shared.managed_worktrees.lock()
23244 .unwrap_or_else(|poisoned| poisoned.into_inner())
23245 .get(&lease.lease_id).unwrap().clone();
23246 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23247 assert!(shared.workspaces.read()
23248 .unwrap_or_else(|poisoned| poisoned.into_inner())
23249 .contains_key(&lease.workspace_id));
23250 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23251 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23252 assert!(!Path::new(&created.path).exists());
23253 std::fs::remove_dir_all(root).unwrap();
23254 }
23255
23256 fn run_test_git(root: &Path, arguments: &[&str]) {
23257 let output = std::process::Command::new("git")
23258 .arg("-C").arg(root).args(arguments).output().unwrap();
23259 assert!(
23260 output.status.success(),
23261 "git {:?} failed: {}",
23262 arguments,
23263 String::from_utf8_lossy(&output.stderr),
23264 );
23265 }
23266
23267 #[tokio::test]
23268 async fn spawn_spec_validation_fails_before_session_mutation() {
23269 let shared = terminal_test_shared();
23270 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23271 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23272 }));
23273 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23274 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23275 }));
23276 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23277 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23278 }));
23279
23280 let mut target_mismatch = spawn_spec_fixture();
23281 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23282 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23283 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23284
23285 let mut unknown_profile = spawn_spec_fixture();
23286 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23287 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23288 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23289 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23290
23291 let mut revision_mismatch = spawn_spec_fixture();
23292 revision_mismatch.idempotency_key =
23293 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23294 revision_mismatch.expected_profile_revision =
23295 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23296 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23297 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23298
23299 let mut unsupported_materializer = spawn_spec_fixture();
23300 unsupported_materializer.idempotency_key =
23301 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23302 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23303 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23304 };
23305 let failure = shared
23306 .spawn_from_spec(unsupported_materializer)
23307 .await
23308 .unwrap_err();
23309 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23310
23311 let failure = shared
23312 .spawn_session_with_deadline(
23313 WorkspaceId::new("primary").unwrap(),
23314 agent("claude"),
23315 SessionMode::Pty,
23316 gate4agent_types::TerminalSize {
23317 rows: 24,
23318 columns: 80,
23319 },
23320 None,
23321 None,
23322 None,
23323 None,
23324 None,
23325 None,
23326 SpawnRecordPolicy::ProviderIdentityOnly,
23327 Some(Instant::now()),
23328 &[],
23329 None,
23330 ApprovalLevel::default(),
23331 None,
23332 None,
23333 )
23334 .await
23335 .unwrap_err();
23336 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23337 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23338 assert!(shared.handle.snapshot().sessions.is_empty());
23339 }
23340
23341 #[test]
23342 fn worktree_selection_requires_its_separate_negotiated_capability() {
23343 let mut spec = spawn_spec_fixture();
23344 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23345 let request = NodeRequest::SpawnSpec { spec };
23346 let spawn_spec_capability =
23347 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23348 assert!(request_uses_unnegotiated_capability(
23349 &request,
23350 &[spawn_spec_capability.clone()],
23351 ));
23352 let worktree_capability =
23353 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23354 assert!(request_uses_unnegotiated_capability(
23355 &request,
23356 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23357 ));
23358 let profile_revision_capability =
23359 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23360 assert!(request_uses_unnegotiated_capability(
23361 &request,
23362 &[
23363 spawn_spec_capability.clone(),
23364 worktree_capability.clone(),
23365 profile_revision_capability.clone(),
23366 ],
23367 ));
23368 let bundle_materialization_capability =
23375 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23376 assert!(request_uses_unnegotiated_capability(
23377 &request,
23378 &[
23379 spawn_spec_capability.clone(),
23380 worktree_capability.clone(),
23381 profile_revision_capability.clone(),
23382 bundle_materialization_capability.clone(),
23383 ],
23384 ));
23385 let history_context_pack_capability =
23386 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23387 assert!(!request_uses_unnegotiated_capability(
23388 &request,
23389 &[
23390 spawn_spec_capability,
23391 worktree_capability,
23392 profile_revision_capability,
23393 bundle_materialization_capability,
23394 history_context_pack_capability,
23395 ],
23396 ));
23397 }
23398
23399 #[tokio::test]
23400 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23401 let mut shared = terminal_test_shared();
23402 let spec = spawn_spec_fixture();
23403 let receipt = shared
23404 .resolve_spawn_spec(&spec)
23405 .unwrap()
23406 .receipt(shared.incarnation_id, terminal_address(1));
23407 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23408
23409 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23410 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23411 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23412
23413 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23414
23415 let mut revision_conflicting = spec.clone();
23416 revision_conflicting.expected_profile_revision =
23417 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23418 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23419 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23420
23421 let mut stale_cache_miss = spec.clone();
23422 stale_cache_miss.idempotency_key =
23423 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23424 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23425 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23426
23427 let mut conflicting = spec;
23428 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23429 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23430 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23431 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23432 assert!(shared.handle.snapshot().sessions.is_empty());
23433 }
23434
23435 #[tokio::test]
23436 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23437 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23438 let mut spec = spawn_spec_fixture();
23439 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23440 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23441 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23442
23443 let failure = shared
23444 .create_worktree(
23445 WorkspaceId::new("primary").unwrap(),
23446 WorkspaceId::new("new-worktree").unwrap(),
23447 "not-an-absolute-path".to_owned(),
23448 "invalid branch".to_owned(),
23449 None,
23450 )
23451 .await
23452 .unwrap_err();
23453 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23454
23455 let failure = shared
23456 .remove_worktree(
23457 WorkspaceId::new("primary").unwrap(),
23458 "not-an-absolute-path".to_owned(),
23459 )
23460 .await
23461 .unwrap_err();
23462 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23463 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23464 assert!(shared.handle.snapshot().sessions.is_empty());
23465 }
23466
23467 #[tokio::test]
23468 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23469 let root = temporary_workspace_root("manual-worktree-compensation");
23470 let source = root.join("source");
23471 let target = root.join("target");
23472 let invalid_state_parent = root.join("state-parent-is-a-file");
23473 let invalid_state_path = invalid_state_parent.join("state.json");
23474 std::fs::create_dir_all(&source).unwrap();
23475 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23476 run_test_git(&source, &["init"]);
23477 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23478 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23479 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23480 run_test_git(&source, &["add", "seed.txt"]);
23481 run_test_git(&source, &["commit", "-m", "seed"]);
23482
23483 let catalog = active_registry().unwrap();
23484 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23485 let workspace = WorkspaceConfig::new(
23486 WorkspaceId::new("primary").unwrap(),
23487 &source,
23488 ).unwrap();
23489 let mut shared = NodeShared::new(
23490 handle,
23491 "fixture-token".to_owned(),
23492 NodeId::new("node-worktree-compensation").unwrap(),
23493 vec![workspace],
23494 vec![agent("claude")],
23495 );
23496 shared.state_path = Some(invalid_state_path);
23497
23498 let error = shared.create_worktree(
23499 WorkspaceId::new("primary").unwrap(),
23500 WorkspaceId::new("compensated").unwrap(),
23501 target.to_string_lossy().into_owned(),
23502 "gate4agent/compensated".to_owned(),
23503 Some("HEAD".to_owned()),
23504 ).await.unwrap_err();
23505
23506 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23507 assert!(!target.exists());
23508 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23509 .iter()
23510 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23511 std::fs::remove_dir_all(root).unwrap();
23512 }
23513
23514 #[test]
23515 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23516 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23517 NativeGitWorktreeSnapshot {
23518 path: path.to_owned(),
23519 head: "0123456789abcdef".to_owned(),
23520 branch: Some("refs/heads/test".to_owned()),
23521 is_bare,
23522 is_main,
23523 locked: false,
23524 lock_reason: None,
23525 prunable: false,
23526 prunable_reason: None,
23527 workspace_id: None,
23528 }
23529 }
23530
23531 let source = r"C:\repo";
23532 let selected = r"C:\repo-worktree";
23533 let listed = vec![
23534 worktree(source, true, false),
23535 worktree(selected, false, false),
23536 ];
23537 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23538
23539 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23540 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23541
23542 let bare = vec![
23543 worktree(source, true, false),
23544 worktree(selected, false, true),
23545 ];
23546 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23547 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23548
23549 let foreign_failure =
23550 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23551 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23552 }
23553
23554 fn terminal_frame(sequence: u64) -> TerminalFrame {
23555 TerminalFrame {
23556 sequence,
23557 size: gate4agent_types::TerminalSize {
23558 rows: 24,
23559 columns: 80,
23560 },
23561 cursor_row: 2,
23562 cursor_column: 3,
23563 contents: format!("frame-{sequence}"),
23564 formatted: vec![1, 2, 3],
23565 scrollback_formatted: Vec::new(),
23566 alternate_screen: false,
23567 mouse_protocol_enabled: false,
23568 mouse_protocol_encoding: Default::default(),
23569 produced_at_unix_ms: 0,
23570 screen_state: gate4agent_types::PtyScreenState::default(),
23571 bracketed_paste: None,
23572 }
23573 }
23574
23575 #[test]
23576 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23577 let shared = terminal_test_shared();
23578 let mut receiver = shared.terminal_event_tx.subscribe();
23579 let original = terminal_address(1);
23580
23581 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23582 let first = receiver.try_recv().unwrap();
23583 assert_eq!(first.len(), 1);
23584 assert!(matches!(
23585 &first[0].event,
23586 NodeEvent::TerminalFrame { address, frame }
23587 if address == &original && frame.sequence == 7
23588 ));
23589
23590 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23591 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23592 assert!(matches!(
23593 receiver.try_recv(),
23594 Err(broadcast::error::TryRecvError::Empty)
23595 ));
23596
23597 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23598 let advanced = receiver.try_recv().unwrap();
23599 assert_eq!(advanced[0].sequence, 2);
23600
23601 let resumed = terminal_address(2);
23602 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23603 let reset = receiver.try_recv().unwrap();
23604 assert_eq!(reset[0].sequence, 3);
23605 assert!(matches!(
23606 &reset[0].event,
23607 NodeEvent::TerminalFrame { address, frame }
23608 if address == &resumed && frame.sequence == 1
23609 ));
23610 shared.clear_terminal_frame_watermark(&resumed);
23611 assert!(!shared
23612 .terminal_frame_watermarks
23613 .lock()
23614 .unwrap_or_else(|poisoned| poisoned.into_inner())
23615 .contains_key(&resumed.session.instance_id));
23616 }
23617
23618 #[test]
23619 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23620 let shared = terminal_test_shared();
23621 let mut receiver = shared.terminal_event_tx.subscribe();
23622 let address = terminal_address(1);
23623 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23624
23625 for sequence in 1..=count {
23626 shared.publish_terminal_frame_candidates(vec![(
23627 address.clone(),
23628 terminal_frame(sequence),
23629 )]);
23630 }
23631
23632 let history = shared
23633 .history
23634 .lock()
23635 .unwrap_or_else(|poisoned| poisoned.into_inner());
23636 assert!(history.events.is_empty());
23637 assert_eq!(history.last_sequence, count);
23638 assert_eq!(history.replay_floor_sequence, 1);
23639 drop(history);
23640 let NodeResponse::Resync {
23641 event_sequence,
23642 oldest_available_sequence,
23643 events,
23644 ..
23645 } = shared.resync(0) else {
23646 unreachable!("resync helper returned another response")
23647 };
23648 assert_eq!(event_sequence, count);
23649 assert_eq!(oldest_available_sequence, 1);
23650 assert!(events.is_empty());
23651 assert!(matches!(
23652 resync_required_event(&shared).event,
23653 NodeEvent::ResyncRequired {
23654 oldest_available_sequence: 1,
23655 }
23656 ));
23657 assert!(matches!(
23658 receiver.try_recv(),
23659 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23660 ));
23661 let latest = receiver.try_recv().unwrap();
23662 assert_eq!(latest.len(), 1);
23663 assert_eq!(latest[0].sequence, count);
23664 }
23665
23666 #[test]
23667 fn real_durable_eviction_advances_authoritative_replay_floor() {
23668 let shared = terminal_test_shared();
23669 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23670 shared.publish(NodeEvent::ControllerChanged { controller: None });
23671 }
23672
23673 let NodeResponse::Resync {
23674 event_sequence,
23675 oldest_available_sequence,
23676 events,
23677 ..
23678 } = shared.resync(0) else {
23679 unreachable!("resync helper returned another response")
23680 };
23681 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23682 assert_eq!(oldest_available_sequence, 2);
23683 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23684 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23685 assert!(matches!(
23686 resync_required_event(&shared).event,
23687 NodeEvent::ResyncRequired {
23688 oldest_available_sequence: 2,
23689 }
23690 ));
23691 }
23692
23693 #[test]
23694 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23695 let shared = terminal_test_shared();
23696 let without_capability = terminal_event_subscription(&shared, false);
23697 let with_capability = terminal_event_subscription(&shared, true);
23698 assert!(without_capability.is_none());
23699 assert!(with_capability.is_some());
23700 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23701 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23702 }));
23703 }
23704
23705 #[test]
23706 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23707 let mut pending = Vec::new();
23708 let mut event_sequence = 0_u64;
23709 for frame_sequence in 1..=4 {
23710 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23711 event_sequence += 1;
23712 let mut address = terminal_address(1);
23713 address.session.instance_id = AgentInstanceId(instance);
23714 queue_connection_event(
23715 &mut pending,
23716 NodeEventEnvelope {
23717 sequence: event_sequence,
23718 event: NodeEvent::TerminalFrame {
23719 address,
23720 frame: terminal_frame(frame_sequence),
23721 },
23722 },
23723 0,
23724 );
23725 }
23726 }
23727 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23728 pending.sort_unstable_by_key(|event| event.sequence);
23729
23730 let mut uninterrupted = pending.clone();
23731 let first_burst = connection_event_burst_len(uninterrupted.len());
23732 uninterrupted.drain(..first_burst);
23733 let second_burst = connection_event_burst_len(uninterrupted.len());
23734 uninterrupted.drain(..second_burst);
23735 assert!(uninterrupted.is_empty());
23736
23737 let (frame_tx, mut frame_rx) =
23738 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23739 frame_tx
23740 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23741 request_id: 77,
23742 request: NodeRequest::Snapshot,
23743 })))
23744 .unwrap();
23745 let burst_len = connection_event_burst_len(pending.len());
23746 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23747 pending.drain(..burst_len);
23748 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23749 assert!(matches!(
23750 frame_rx.try_recv(),
23751 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23752 request_id: 77,
23753 request: NodeRequest::Snapshot,
23754 })))
23755 ));
23756 }
23757
23758 #[test]
23759 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23760 let mut pending = Vec::new();
23761 for sequence in [7_u64, 8] {
23762 let mut address = terminal_address(1);
23763 address.session.instance_id = AgentInstanceId(sequence);
23764 queue_connection_event(
23765 &mut pending,
23766 NodeEventEnvelope {
23767 sequence,
23768 event: NodeEvent::TerminalFrame {
23769 address,
23770 frame: terminal_frame(sequence),
23771 },
23772 },
23773 0,
23774 );
23775 }
23776 let mut discard_through = 0;
23777 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23778 assert!(pending.is_empty());
23779
23780 let replayed = NodeEventEnvelope {
23781 sequence: 8,
23782 event: NodeEvent::TerminalFrame {
23783 address: terminal_address(1),
23784 frame: terminal_frame(8),
23785 },
23786 };
23787 let advanced = NodeEventEnvelope {
23788 sequence: 9,
23789 event: NodeEvent::TerminalFrame {
23790 address: terminal_address(2),
23791 frame: terminal_frame(1),
23792 },
23793 };
23794 queue_connection_event_batch(
23795 &mut pending,
23796 &[replayed, advanced.clone()],
23797 discard_through,
23798 );
23799 assert_eq!(pending, vec![advanced]);
23800 }
23801
23802 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23803 ManagedSessionRecord {
23804 record_id: SessionRecordId::new(record_id).unwrap(),
23805 display_name: format!("{provider} record"),
23806 provider: agent(provider),
23807 mode: SessionMode::Pty,
23808 state: ManagedSessionState::Unavailable,
23809 workspace_id: WorkspaceId::new("primary").unwrap(),
23810 canonical_root: opaque_windows_path(
23811 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23812 ),
23813 provider_session: None,
23814 active_session: None,
23815 environment_profile: None,
23816 bundle: None,
23817 context_id: None,
23818 context: None,
23819 exported_context: None,
23820 task_binding: None,
23821 created_at_unix_ms: 1,
23822 updated_at_unix_ms: 1,
23823 last_error: None,
23824 }
23825 }
23826
23827 fn retention_test_record(
23828 workspace_id: &str,
23829 record_id: &str,
23830 state: ManagedSessionState,
23831 updated_at_unix_ms: u64,
23832 ) -> ManagedSessionRecord {
23833 ManagedSessionRecord {
23834 record_id: SessionRecordId::new(record_id).unwrap(),
23835 display_name: format!("{record_id} display"),
23836 provider: agent("claude"),
23837 mode: SessionMode::Pty,
23838 state,
23839 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23840 canonical_root: opaque_windows_path(
23841 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23842 ),
23843 provider_session: None,
23844 active_session: None,
23845 environment_profile: None,
23846 bundle: None,
23847 context_id: None,
23848 context: None,
23849 exported_context: None,
23850 task_binding: None,
23851 created_at_unix_ms: updated_at_unix_ms,
23852 updated_at_unix_ms,
23853 last_error: None,
23854 }
23855 }
23856
23857 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23865 vec![
23866 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23867 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23868 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23869 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23870 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23871 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23872 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23873 retention_test_record(
23874 "workspace-a",
23875 "decoy-pending",
23876 ManagedSessionState::IdentityPending,
23877 now - 500_000,
23878 ),
23879 retention_test_record(
23880 "workspace-a",
23881 "decoy-dormant",
23882 ManagedSessionState::Dormant,
23883 now - 500_000,
23884 ),
23885 ]
23886 }
23887
23888 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23889 let root = temporary_workspace_root("session-record-retention");
23890 std::fs::create_dir_all(&root).unwrap();
23891 let catalog = active_registry().unwrap();
23892 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23893 let workspace_a =
23894 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23895 let workspace_b =
23896 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23897 let mut shared = NodeShared::new(
23898 handle,
23899 "fixture-token".to_owned(),
23900 NodeId::new("node-retention-test").unwrap(),
23901 vec![workspace_a, workspace_b],
23902 vec![agent("claude")],
23903 );
23904 shared.state_path = Some(root.join("node-state-v10.json"));
23905 let now = unix_time_ms();
23906 for record in retention_fixture_records(now) {
23907 shared.insert_record(record).unwrap();
23908 }
23909 shared
23910 .persist_state_locked()
23911 .expect("fixture-only, empty-history persist against a fresh temp state path");
23912 (shared, runtime, root, now)
23913 }
23914
23915 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23916 shared
23917 .history
23918 .lock()
23919 .unwrap_or_else(|poisoned| poisoned.into_inner())
23920 .events
23921 .iter()
23922 .filter_map(|envelope| match &envelope.event {
23923 NodeEvent::SessionRecordRemoved { record_id } => {
23924 Some(record_id.as_str().to_owned())
23925 }
23926 _ => None,
23927 })
23928 .collect()
23929 }
23930
23931 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23932 shared
23933 .session_records
23934 .lock()
23935 .unwrap_or_else(|poisoned| poisoned.into_inner())
23936 .records
23937 .keys()
23938 .map(|record_id| record_id.as_str().to_owned())
23939 .collect()
23940 }
23941
23942 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23943 values.iter().map(|value| (*value).to_owned()).collect()
23944 }
23945
23946 #[tokio::test]
23947 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23948 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23949 shared.session_record_retention = SessionRecordRetentionConfig {
23950 age_ms: 0,
23951 keep_per_workspace: 1,
23952 };
23953 shared.retire_unavailable_session_records().await;
23954
23955 assert_eq!(
23956 retention_remaining_record_ids(&shared),
23957 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23958 );
23959 assert_eq!(
23960 retention_removed_record_ids(&shared),
23961 owned(&["a1", "a2", "b1", "b2"]),
23962 );
23963 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23964
23965 drop(shared);
23966 drop(runtime);
23967 std::fs::remove_dir_all(root).unwrap();
23968 }
23969
23970 #[tokio::test]
23976 async fn session_record_retention_both_set_retires_only_the_intersection() {
23977 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23978 shared.session_record_retention = SessionRecordRetentionConfig {
23979 age_ms: 250_000,
23980 keep_per_workspace: 1,
23981 };
23982 shared.retire_unavailable_session_records().await;
23983
23984 assert_eq!(
23985 retention_remaining_record_ids(&shared),
23986 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23987 );
23988 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23989 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23990
23991 drop(shared);
23992 drop(runtime);
23993 std::fs::remove_dir_all(root).unwrap();
23994 }
23995
23996 #[tokio::test]
23997 async fn session_record_retention_both_disabled_is_a_no_op() {
23998 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23999 shared.session_record_retention = SessionRecordRetentionConfig::default();
24000 shared.retire_unavailable_session_records().await;
24001
24002 assert_eq!(
24003 retention_remaining_record_ids(&shared),
24004 owned(&[
24005 "a1", "a2", "a3", "b1", "b2", "b3",
24006 "decoy-live", "decoy-pending", "decoy-dormant",
24007 ]),
24008 );
24009 assert!(retention_removed_record_ids(&shared).is_empty());
24010 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
24011
24012 drop(shared);
24013 drop(runtime);
24014 std::fs::remove_dir_all(root).unwrap();
24015 }
24016
24017 #[test]
24018 fn session_record_context_export_selects_one_exact_history_identity() {
24019 let candidates = vec![
24020 HistoryCandidateSummary {
24021 id: "candidate-other".to_owned(),
24022 session_id_hint: "provider-other".to_owned(),
24023 modified_at_unix_ms: Some(1),
24024 },
24025 HistoryCandidateSummary {
24026 id: "candidate-exact".to_owned(),
24027 session_id_hint: "provider-exact".to_owned(),
24028 modified_at_unix_ms: Some(2),
24029 },
24030 ];
24031 assert_eq!(
24032 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24033 "candidate-exact",
24034 );
24035 }
24036
24037 #[test]
24038 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24039 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24040 id: id.to_owned(),
24041 session_id_hint: hint.to_owned(),
24042 modified_at_unix_ms: None,
24043 };
24044 let missing = select_history_candidate(
24045 &[candidate("candidate-other", "provider-other")],
24046 Some("provider-exact"),
24047 )
24048 .unwrap_err();
24049 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24050 let ambiguous = select_history_candidate(
24051 &[
24052 candidate("candidate-a", "provider-exact"),
24053 candidate("candidate-b", "provider-exact"),
24054 ],
24055 Some("provider-exact"),
24056 )
24057 .unwrap_err();
24058 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24059 }
24060
24061 #[test]
24069 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24070 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24071 id: id.to_owned(),
24072 session_id_hint: hint.to_owned(),
24073 modified_at_unix_ms: None,
24074 };
24075 assert_eq!(
24076 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24077 .unwrap(),
24078 "candidate-sole",
24079 );
24080 let missing = select_history_candidate(&[], None).unwrap_err();
24081 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24082 let ambiguous = select_history_candidate(
24083 &[
24084 candidate("candidate-a", "sidecar-session-a"),
24085 candidate("candidate-b", "sidecar-session-b"),
24086 ],
24087 None,
24088 )
24089 .unwrap_err();
24090 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24091 }
24092
24093 #[test]
24094 fn session_record_context_export_revalidation_rejects_record_change() {
24095 let session = terminal_address(1);
24096 let identity = ProviderSessionIdentity {
24097 key: ProviderSessionKey::SessionId,
24098 id: "provider-exact".to_owned(),
24099 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24100 };
24101 let mut expected = record("claude", "record-context-export");
24102 expected.state = ManagedSessionState::Live;
24103 expected.provider_session = Some(identity.clone());
24104 expected.active_session = Some(session.clone());
24105 let binding = SessionBinding {
24106 workspace_id: session.workspace_id.clone(),
24107 generation: session.session.generation,
24108 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24109 pending_resume: None,
24110 record_id: Some(expected.record_id.clone()),
24111 managed_worktree_lease_id: None,
24112 environment_profile: None,
24113 bundle: None,
24114 context: None,
24115 materialization_id: None,
24116 };
24117 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24118 assert!(session_record_context_export_binding_is_exact(
24119 &expected,
24120 &expected,
24121 Some(&identity),
24122 &session,
24123 &binding,
24124 &expected.provider,
24125 ¤t_root,
24126 false,
24127 ));
24128 let mut changed = expected.clone();
24129 changed.updated_at_unix_ms += 1;
24130 assert!(!session_record_context_export_binding_is_exact(
24131 &expected,
24132 &changed,
24133 Some(&identity),
24134 &session,
24135 &binding,
24136 &expected.provider,
24137 ¤t_root,
24138 false,
24139 ));
24140 assert!(!session_record_context_export_binding_is_exact(
24145 &expected,
24146 &changed,
24147 Some(&identity),
24148 &session,
24149 &binding,
24150 &expected.provider,
24151 ¤t_root,
24152 true,
24153 ));
24154 let mut detached = expected.clone();
24155 detached.state = ManagedSessionState::Dormant;
24156 detached.active_session = None;
24157 detached.updated_at_unix_ms += 1;
24158 assert!(!session_record_context_export_binding_is_exact(
24159 &expected,
24160 &detached,
24161 Some(&identity),
24162 &session,
24163 &binding,
24164 &expected.provider,
24165 ¤t_root,
24166 false,
24167 ));
24168 assert!(session_record_context_export_binding_is_exact(
24169 &expected,
24170 &detached,
24171 Some(&identity),
24172 &session,
24173 &binding,
24174 &expected.provider,
24175 ¤t_root,
24176 true,
24177 ));
24178 }
24179
24180 #[test]
24187 fn session_record_context_export_binding_is_exact_without_identity() {
24188 let session = terminal_address(1);
24189 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24190 expected.state = ManagedSessionState::Live;
24191 expected.active_session = Some(session.clone());
24192 let binding = SessionBinding {
24193 workspace_id: session.workspace_id.clone(),
24194 generation: session.session.generation,
24195 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24196 pending_resume: None,
24197 record_id: Some(expected.record_id.clone()),
24198 managed_worktree_lease_id: None,
24199 environment_profile: None,
24200 bundle: None,
24201 context: None,
24202 materialization_id: None,
24203 };
24204 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24205 assert!(session_record_context_export_binding_is_exact(
24206 &expected,
24207 &expected,
24208 None,
24209 &session,
24210 &binding,
24211 &expected.provider,
24212 ¤t_root,
24213 false,
24214 ));
24215 let mut drifted = expected.clone();
24219 drifted.provider_session = Some(ProviderSessionIdentity {
24220 key: ProviderSessionKey::SessionId,
24221 id: "unexpected-identity".to_owned(),
24222 transcript_path: None,
24223 });
24224 assert!(!session_record_context_export_binding_is_exact(
24225 &expected,
24226 &drifted,
24227 None,
24228 &session,
24229 &binding,
24230 &expected.provider,
24231 ¤t_root,
24232 false,
24233 ));
24234 }
24235
24236 #[test]
24237 fn session_record_context_export_preflight_requires_live_usable_source() {
24238 let mut source = record("claude", "record-context-export-preflight");
24239 source.state = ManagedSessionState::Live;
24240 assert!(session_record_context_export_source_is_usable(
24241 &source,
24242 &SessionStatus::Running,
24243 false,
24244 ));
24245 assert!(session_record_context_export_source_is_usable(
24246 &source,
24247 &SessionStatus::Exited { exit_code: Some(0) },
24248 false,
24249 ));
24250
24251 source.state = ManagedSessionState::Dormant;
24252 assert!(!session_record_context_export_source_is_usable(
24253 &source,
24254 &SessionStatus::Running,
24255 false,
24256 ));
24257 assert!(session_record_context_export_source_is_usable(
24261 &source,
24262 &SessionStatus::Exited { exit_code: Some(0) },
24263 true,
24264 ));
24265 assert!(!session_record_context_export_source_is_usable(
24266 &source,
24267 &SessionStatus::Exited { exit_code: Some(0) },
24268 false,
24269 ));
24270 source.state = ManagedSessionState::Live;
24271 assert!(!session_record_context_export_source_is_usable(
24272 &source,
24273 &SessionStatus::Starting,
24274 false,
24275 ));
24276 }
24277
24278 #[test]
24279 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24280 let catalog = active_registry().unwrap();
24281 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24282 let workspace = WorkspaceConfig::new(
24283 WorkspaceId::new("primary").unwrap(),
24284 std::env::current_dir().unwrap(),
24285 )
24286 .unwrap();
24287 let shared = NodeShared::new(
24288 handle,
24289 "fixture-token".to_owned(),
24290 NodeId::new("node-context-export-conflict").unwrap(),
24291 vec![workspace],
24292 vec![agent("claude")],
24293 );
24294 let history = HistorySessionRecord {
24295 session_id: "provider-exact".to_owned(),
24296 title: None,
24297 cwd: None,
24298 model: None,
24299 message_count: 1,
24300 completed_turn_count: None,
24301 total_tokens: 0,
24302 messages: vec![gate4agent_types::HistoryMessageRecord {
24303 role: gate4agent_types::HistoryMessageRole::User,
24304 text: "bounded context".to_owned(),
24305 }],
24306 };
24307 let pack = NodeContextPack::export(
24308 ContextPackLineageReceipt {
24309 source_node_id: shared.node_id.clone(),
24310 source_session: terminal_address(1),
24311 source_provider: agent("claude"),
24312 },
24313 &history,
24314 )
24315 .unwrap();
24316 let context_id = pack.receipt().id.clone();
24317 let session = terminal_address(1);
24318 let identity = ProviderSessionIdentity {
24319 key: ProviderSessionKey::SessionId,
24320 id: history.session_id.clone(),
24321 transcript_path: None,
24322 };
24323 let mut expected = record("claude", "record-context-export-final-conflict");
24324 expected.state = ManagedSessionState::Live;
24325 expected.provider_session = Some(identity.clone());
24326 expected.active_session = Some(session.clone());
24327 let mut changed = expected.clone();
24328 changed.updated_at_unix_ms += 1;
24329 shared.insert_record(changed).unwrap();
24330
24331 let error = shared
24332 .commit_context_pack_for_session_record(
24333 &expected,
24334 Some(&identity),
24335 &session,
24336 "candidate-exact",
24337 &history,
24338 pack,
24339 false,
24340 )
24341 .unwrap_err();
24342
24343 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24344 assert!(shared
24345 .context_catalog
24346 .read()
24347 .unwrap_or_else(|poisoned| poisoned.into_inner())
24348 .get(&context_id)
24349 .is_none());
24350 }
24351
24352 #[tokio::test]
24353 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24354 let root = temporary_workspace_root("session-task-binding");
24355 std::fs::create_dir_all(&root).unwrap();
24356 let state_path = root.join("node-state.json");
24357 let catalog = active_registry().unwrap();
24358 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24359 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24360 let node_id = NodeId::new("node-1").unwrap();
24361 let mut shared = NodeShared::new(
24362 handle,
24363 "fixture-token".to_owned(),
24364 node_id.clone(),
24365 vec![workspace],
24366 vec![agent("claude")],
24367 );
24368 shared.state_path = Some(state_path.clone());
24369 let record = record("claude", "sr-task");
24370 let record_id = record.record_id.clone();
24371 shared.insert_record(record).unwrap();
24372 shared.persist_state().unwrap();
24373 shared
24374 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24375 .unwrap();
24376
24377 let minted = process_request_inner(
24378 &shared,
24379 77,
24380 ClientRole::Operator,
24381 NodeRequest::SetSessionTask {
24382 record_id: record_id.clone(),
24383 expected_revision: 0,
24384 target: SessionTaskTargetV1::New,
24385 },
24386 )
24387 .await
24388 .unwrap();
24389 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24390 panic!("session task mutation returned another response");
24391 };
24392 let minted_binding = minted.task_binding.clone().unwrap();
24393 let minted_task_id = minted_binding.task_id.clone().unwrap();
24394 assert_eq!(minted_binding.revision, 1);
24395 assert!(minted_task_id.as_str().starts_with("task-"));
24396 assert_eq!(minted_task_id.as_str().len(), 29);
24397
24398 let event_count = shared.current_sequence();
24399 let repeated = process_request_inner(
24400 &shared,
24401 77,
24402 ClientRole::Operator,
24403 NodeRequest::SetSessionTask {
24404 record_id: record_id.clone(),
24405 expected_revision: 1,
24406 target: SessionTaskTargetV1::Existing {
24407 task_id: minted_task_id.clone(),
24408 },
24409 },
24410 )
24411 .await
24412 .unwrap();
24413 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24414 assert_eq!(shared.current_sequence(), event_count);
24415
24416 let stale = process_request_inner(
24417 &shared,
24418 77,
24419 ClientRole::Operator,
24420 NodeRequest::SetSessionTask {
24421 record_id: record_id.clone(),
24422 expected_revision: 0,
24423 target: SessionTaskTargetV1::Existing {
24424 task_id: minted_task_id,
24425 },
24426 },
24427 )
24428 .await
24429 .unwrap_err();
24430 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24431
24432 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24433 let assigned = process_request_inner(
24434 &shared,
24435 77,
24436 ClientRole::Operator,
24437 NodeRequest::SetSessionTask {
24438 record_id: record_id.clone(),
24439 expected_revision: 1,
24440 target: SessionTaskTargetV1::Existing {
24441 task_id: assigned_id.clone(),
24442 },
24443 },
24444 )
24445 .await
24446 .unwrap();
24447 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24448 panic!("session task assignment returned another response");
24449 };
24450 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24451 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24452
24453 let cleared = process_request_inner(
24454 &shared,
24455 77,
24456 ClientRole::Operator,
24457 NodeRequest::SetSessionTask {
24458 record_id: record_id.clone(),
24459 expected_revision: 2,
24460 target: SessionTaskTargetV1::Clear,
24461 },
24462 )
24463 .await
24464 .unwrap();
24465 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24466 panic!("session task clear returned another response");
24467 };
24468 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24469 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24470 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24471 assert_eq!(
24472 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24473 cleared.updated_at_unix_ms,
24474 );
24475
24476 let persisted = std::fs::read(&state_path).unwrap();
24477 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24478 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24479 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24480 std::fs::remove_dir_all(root).unwrap();
24481 }
24482
24483 #[test]
24484 fn session_task_capability_strips_snapshots_events_and_replies() {
24485 let mut bound = record("claude", "sr-task-projection");
24486 bound.updated_at_unix_ms = 2;
24487 bound.task_binding = Some(SessionTaskBindingV1 {
24488 revision: 1,
24489 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24490 changed_at_unix_ms: 2,
24491 });
24492 let request = NodeRequest::SetSessionTask {
24493 record_id: bound.record_id.clone(),
24494 expected_revision: 1,
24495 target: SessionTaskTargetV1::Clear,
24496 };
24497 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24498 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24499 }));
24500 assert!(!request_uses_unnegotiated_capability(
24501 &request,
24502 &baseline_capabilities().unwrap(),
24503 ));
24504 assert!(request_uses_unnegotiated_capability(&request, &[]));
24505
24506 let mut snapshot = terminal_test_shared().snapshot();
24507 snapshot.session_records.push(bound.clone());
24508 let mut reply = ResponseEnvelope {
24509 request_id: 1,
24510 result: Ok(NodeResponse::Resync {
24511 event_sequence: 1,
24512 oldest_available_sequence: 1,
24513 snapshot,
24514 events: vec![NodeEventEnvelope {
24515 sequence: 1,
24516 event: NodeEvent::SessionRecordUpserted {
24517 record: bound.clone(),
24518 },
24519 }],
24520 }),
24521 };
24522 project_response_without_session_task_binding(&mut reply);
24523 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24524 panic!("task projection changed the response kind");
24525 };
24526 assert!(snapshot.session_records[0].task_binding.is_none());
24527 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24528 panic!("task projection changed the event kind");
24529 };
24530 assert!(record.task_binding.is_none());
24531
24532 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24533 sequence: 2,
24534 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24535 });
24536 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24537 panic!("task event projection changed the event kind");
24538 };
24539 assert!(record.task_binding.is_none());
24540
24541 let mut direct = ResponseEnvelope {
24542 request_id: 2,
24543 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24544 };
24545 project_response_without_session_task_binding(&mut direct);
24546 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24547 panic!("task reply projection changed the response kind");
24548 };
24549 assert!(record.task_binding.is_none());
24550 }
24551
24552 #[test]
24553 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24554 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24555 let workspace = WorkspaceConfig::new(
24556 WorkspaceId::new("primary").unwrap(),
24557 std::env::current_dir().unwrap(),
24558 )
24559 .unwrap();
24560 let config = NodeServerConfig::new(
24561 DEFAULT_NODE_ENDPOINT,
24562 "fixture-token",
24563 NodeId::new("node-1").unwrap(),
24564 [workspace],
24565 )
24566 .unwrap();
24567 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24568 }
24569
24570 #[cfg(all(feature = "fixture", windows))]
24571 #[test]
24572 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24573 let root = temporary_workspace_root("provider-bundle-argv-hold");
24574 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24575 std::fs::create_dir_all(&root).unwrap();
24576 std::fs::create_dir_all(&outside).unwrap();
24577 let proof_path = root.join("provider.proof");
24578 let release_signal = root.join("release.signal");
24579 let config = NodeServerConfig::new(
24580 format!(
24581 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24582 std::process::id(),
24583 ),
24584 "fixture-token",
24585 NodeId::new("provider-bundle-hold-node").unwrap(),
24586 [WorkspaceConfig::new(
24587 WorkspaceId::new("primary").unwrap(),
24588 std::env::current_dir().unwrap(),
24589 )
24590 .unwrap()],
24591 )
24592 .unwrap()
24593 .with_spawn_profiles(SpawnProfileRegistry::new([
24594 crate::protocol::SpawnProfileDefaults {
24595 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24596 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24597 .unwrap(),
24598 provider: agent("kimi"),
24599 mode: SessionMode::Pty,
24600 terminal_size: gate4agent_types::TerminalSize {
24601 rows: 24,
24602 columns: 80,
24603 },
24604 prompt: None,
24605 bundle_id: None,
24606 context_id: None,
24607 environment_profile_id: None,
24608 },
24609 ])
24610 .unwrap());
24611 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24612 config,
24613 agent("kimi"),
24614 proof_path.clone(),
24615 release_signal.clone(),
24616 )
24617 .unwrap();
24618 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24619
24620 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24621 agent("kimi"),
24622 proof_path.clone(),
24623 Some(release_signal.clone()),
24624 )
24625 .unwrap();
24626 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24627 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24628 let release_wait = wrapper
24629 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24630 .unwrap();
24631 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24632 assert!(proof_write < release_wait);
24633 assert!(release_wait < fixture_ready);
24634 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24635 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24636 assert_eq!(
24637 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24638 &[
24639 proof_path.to_string_lossy().into_owned(),
24640 release_signal.to_string_lossy().into_owned(),
24641 ],
24642 );
24643
24644 let error = NodeServer::provider_bundle_argv_fixture_spec(
24645 agent("kimi"),
24646 proof_path,
24647 Some(outside.join("release.signal")),
24648 )
24649 .unwrap_err();
24650 assert!(matches!(
24651 error,
24652 NodeServerError::Registry(message)
24653 if message == "provider bundle argv release path must share the proof parent"
24654 ));
24655 drop(server);
24656 std::fs::remove_dir_all(root).unwrap();
24657 std::fs::remove_dir_all(outside).unwrap();
24658 }
24659
24660 #[cfg(feature = "fixture")]
24661 #[test]
24662 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24663 let root = temporary_workspace_root("context-fixture-registry");
24664 std::fs::create_dir_all(&root).unwrap();
24665 let proof_path = root.join("context.proof");
24666 let config = NodeServerConfig::new(
24667 format!(
24668 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24669 std::process::id(),
24670 ),
24671 "fixture-token",
24672 NodeId::new("context-fixture-node").unwrap(),
24673 [WorkspaceConfig::new(
24674 WorkspaceId::new("primary").unwrap(),
24675 std::env::current_dir().unwrap(),
24676 )
24677 .unwrap()],
24678 )
24679 .unwrap();
24680 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24681 let mut enabled = server
24682 .shared
24683 .enabled_providers
24684 .iter()
24685 .map(AgentId::as_str)
24686 .collect::<Vec<_>>();
24687 enabled.sort_unstable();
24688 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24689 for provider in &server.shared.enabled_providers {
24690 assert!(server
24691 .shared
24692 .provider_adapter_contracts
24693 .iter()
24694 .any(|contract| {
24695 &contract.provider == provider
24696 && contract.family == AdapterFamily::History
24697 }));
24698 }
24699 drop(server);
24700 std::fs::remove_dir_all(root).unwrap();
24701 }
24702
24703 #[cfg(feature = "fixture")]
24704 #[test]
24705 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24706 let root = temporary_workspace_root("monitoring-context-fixture");
24707 std::fs::create_dir_all(&root).unwrap();
24708 let proof_path = root.join("context.proof");
24709 let standard = NodeServer::context_pack_fixture_catalog(
24710 proof_path.clone(),
24711 None,
24712 false,
24713 ).unwrap();
24714 let monitoring = NodeServer::context_pack_fixture_catalog(
24715 proof_path.clone(),
24716 None,
24717 true,
24718 ).unwrap();
24719 let claude = AgentId::new("claude").unwrap();
24720 let standard_claude = standard.get(&claude).unwrap();
24721 let monitoring_claude = monitoring.get(&claude).unwrap();
24722 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24726 assert_eq!(
24727 monitoring_claude.capabilities.adapters.history,
24728 standard_claude.capabilities.adapters.history,
24729 );
24730 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24731 let codex = AgentId::new("codex").unwrap();
24732 let standard_codex = standard.get(&codex).unwrap();
24733 let monitoring_codex = monitoring.get(&codex).unwrap();
24734 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24735 assert_eq!(
24736 monitoring_codex.capabilities.adapters.history,
24737 standard_codex.capabilities.adapters.history,
24738 );
24739 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24740 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24741 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24742 .unwrap();
24743 for required in [
24744 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24745 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24746 "@('role', 'text')",
24747 "g4a-private-provider-session-canary",
24748 "private-provider-login-identity",
24749 "$rawProof",
24750 "$env:GATE4AGENT_HOOK_URL",
24751 "g4a-private-provider-session-canary",
24752 ] {
24753 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24754 }
24755 for provider in ["grok", "kimi"] {
24756 let provider = AgentId::new(provider).unwrap();
24757 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24758 }
24759
24760 let config = NodeServerConfig::new(
24761 format!(
24762 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24763 std::process::id(),
24764 ),
24765 "fixture-token",
24766 NodeId::new("monitoring-context-fixture-node").unwrap(),
24767 [WorkspaceConfig::new(
24768 WorkspaceId::new("primary").unwrap(),
24769 std::env::current_dir().unwrap(),
24770 )
24771 .unwrap()],
24772 )
24773 .unwrap();
24774 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24775 assert!(server.shared.fixture_semantic_hook_policy);
24776 drop(server);
24777 std::fs::remove_dir_all(root).unwrap();
24778 }
24779
24780 #[cfg(feature = "fixture")]
24781 #[test]
24782 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24783 let root = temporary_workspace_root("kimi-context-only-fixture");
24784 std::fs::create_dir_all(&root).unwrap();
24785 let proof_path = root.join("context.proof");
24786 let standard = NodeServer::context_pack_fixture_catalog(
24787 proof_path.clone(),
24788 None,
24789 false,
24790 ).unwrap();
24791 let kimi = AgentId::new("kimi").unwrap();
24792 let context_only = NodeServer::context_pack_fixture_catalog(
24793 proof_path,
24794 Some(&kimi),
24795 false,
24796 )
24797 .unwrap();
24798
24799 let codex = AgentId::new("codex").unwrap();
24800 assert_eq!(
24801 standard.get(&codex).unwrap().launch.fixed_args,
24802 context_only.get(&codex).unwrap().launch.fixed_args,
24803 );
24804 let script = context_only
24805 .get(&kimi)
24806 .unwrap()
24807 .launch
24808 .fixed_args
24809 .iter()
24810 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24811 .expect("Kimi context-only validation script is missing");
24812 for required in [
24813 "$bundleArgs.Count -ne 0",
24814 "GATE4AGENT_CONTEXT_ROOT",
24815 "context-pack.json",
24816 "$entries.Count -ne 1",
24817 "g4a-context-pack-v1",
24818 "$document.PSObject.Properties['cwd']",
24819 "'claude', 'codex', 'grok', 'kimi'",
24820 "$hasUser",
24821 "$hasAssistant",
24822 "SHA256",
24823 "context-only",
24824 ] {
24825 assert!(script.contains(required), "missing validation: {required}");
24826 }
24827 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24828 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24829 }
24830 assert!(!standard
24831 .get(&kimi)
24832 .unwrap()
24833 .launch
24834 .fixed_args
24835 .iter()
24836 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24837
24838 std::fs::remove_dir_all(root).unwrap();
24839 }
24840
24841 #[cfg(all(feature = "fixture", windows))]
24842 #[tokio::test]
24843 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24844 let root = temporary_workspace_root("kimi-context-only-spawn");
24845 let workspace_root = root.join("workspace");
24846 std::fs::create_dir_all(&workspace_root).unwrap();
24847 let materialization_root = root.join("private-materializations");
24848 let proof_path = root.join("context.proof");
24849 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24850 let workspace_id = WorkspaceId::new("primary").unwrap();
24851 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24852 let endpoint = format!(
24853 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24854 std::process::id(),
24855 unix_time_ms(),
24856 );
24857 let spawn_profiles = SpawnProfileRegistry::new([
24858 crate::protocol::SpawnProfileDefaults {
24859 profile_id: profile_id.clone(),
24860 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24861 provider: agent("kimi"),
24862 mode: SessionMode::Pty,
24863 terminal_size: gate4agent_types::TerminalSize {
24864 rows: 30,
24865 columns: 120,
24866 },
24867 prompt: None,
24868 bundle_id: None,
24869 context_id: None,
24870 environment_profile_id: None,
24871 },
24872 ])
24873 .unwrap();
24874 let mut config = NodeServerConfig::new(
24875 &endpoint,
24876 "fixture-token",
24877 node_id.clone(),
24878 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24879 )
24880 .unwrap()
24881 .with_spawn_profiles(spawn_profiles)
24882 .with_session_environment_materialization(
24883 materialization_root,
24884 Arc::new(FixtureSecretResolver {
24885 deny: Arc::new(AtomicBool::new(false)),
24886 }),
24887 )
24888 .unwrap();
24889 config.fixture_raw_pty_runtime = true;
24890 let server = NodeServer::new_context_only_proof_fixture(
24891 config,
24892 agent("kimi"),
24893 proof_path.clone(),
24894 )
24895 .unwrap();
24896 let shared = Arc::clone(&server.shared);
24897 let shutdown = server.shutdown_handle();
24898
24899 let history = HistorySessionRecord {
24900 session_id: "context-only-source-history".to_owned(),
24901 title: None,
24902 cwd: None,
24903 model: None,
24904 message_count: 2,
24905 completed_turn_count: None,
24906 total_tokens: 0,
24907 messages: vec![
24908 gate4agent_types::HistoryMessageRecord {
24909 role: gate4agent_types::HistoryMessageRole::User,
24910 text: "continue the bounded parent run through native C2".to_owned(),
24911 },
24912 gate4agent_types::HistoryMessageRecord {
24913 role: gate4agent_types::HistoryMessageRole::Assistant,
24914 text: "the exact parent context is ready for the child run".to_owned(),
24915 },
24916 ],
24917 };
24918 let pack = NodeContextPack::export(
24919 ContextPackLineageReceipt {
24920 source_node_id: node_id.clone(),
24921 source_session: SessionAddress {
24922 workspace_id: workspace_id.clone(),
24923 session: SessionKey {
24924 instance_id: AgentInstanceId(71),
24925 generation: SessionGeneration(1),
24926 },
24927 },
24928 source_provider: agent("codex"),
24929 },
24930 &history,
24931 )
24932 .unwrap();
24933 let expected_context_hash = digest(&SHA256, pack.bytes())
24934 .as_ref()
24935 .iter()
24936 .map(|byte| format!("{byte:02x}"))
24937 .collect::<String>();
24938 let context = shared
24939 .context_catalog
24940 .write()
24941 .unwrap_or_else(|poisoned| poisoned.into_inner())
24942 .insert(pack)
24943 .unwrap();
24944 let spec = SpawnSpec {
24945 target: crate::protocol::SpawnTarget {
24946 node_id,
24947 workspace_id: workspace_id.clone(),
24948 worktree_id: None,
24949 },
24950 profile_id,
24951 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24952 "target-kimi-r1",
24953 ).unwrap(),
24954 overrides: crate::protocol::SpawnOverrides {
24955 context_id: crate::protocol::SpawnOverride::Set {
24956 value: context.id.clone(),
24957 },
24958 ..crate::protocol::SpawnOverrides::default()
24959 },
24960 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24961 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24962 required_capabilities: SpawnRequiredCapabilities::default(),
24963 };
24964 let resolved = match shared.resolve_spawn_spec(&spec) {
24965 Ok(resolved) => resolved,
24966 Err(error) => panic!(
24967 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24968 error.code,
24969 error.message,
24970 ),
24971 };
24972 let resolved_context = match shared.resolve_context(&resolved) {
24973 Ok(Some(resolved_context)) => resolved_context,
24974 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24975 Err(error) => panic!(
24976 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24977 error.code,
24978 error.message,
24979 ),
24980 };
24981 assert_eq!(resolved_context, context);
24982 let runtime_policy = match shared
24983 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24984 .await
24985 {
24986 Ok(runtime_policy) => runtime_policy,
24987 Err(error) => panic!(
24988 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24989 error.code,
24990 error.message,
24991 ),
24992 };
24993 assert!(runtime_policy.raw_pty_lifecycle);
24994
24995 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24996 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24997 agent("kimi"),
24998 TransportKind::Pty,
24999 vec![EnvMutation {
25000 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
25001 value: Some(OsString::from("must-remain-rejected")),
25002 }],
25003 )
25004 .unwrap();
25005 assert_eq!(
25006 launch_control
25007 .install_native_launch_environment_overlay(
25008 AgentInstanceId(72),
25009 generic_overlay,
25010 )
25011 .unwrap_err(),
25012 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
25013 );
25014
25015 let diagnostic_address = SessionAddress {
25016 workspace_id: workspace_id.clone(),
25017 session: SessionKey {
25018 instance_id: AgentInstanceId(73),
25019 generation: SessionGeneration(1),
25020 },
25021 };
25022 let (diagnostic_overlay, diagnostic_materialization) = match shared
25023 .prepare_session_materialization(
25024 &diagnostic_address,
25025 &agent("kimi"),
25026 SessionMode::Pty,
25027 None,
25028 None,
25029 Some(&resolved_context),
25030 None,
25031 )
25032 {
25033 Ok(Some(prepared)) => prepared,
25034 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25035 Err(error) => panic!(
25036 "Kimi context-only materialization rejected: exact_code={:?} message={}",
25037 error.code,
25038 error.message,
25039 ),
25040 };
25041 let diagnostic_overlay = diagnostic_overlay
25042 .expect("Kimi context-only materialization omitted its environment overlay");
25043 assert!(matches!(
25044 &diagnostic_overlay,
25045 PreparedNativeLaunchOverlay::Environment(_),
25046 ));
25047 match shared.install_prepared_launch_overlay(
25048 diagnostic_address.session.instance_id,
25049 diagnostic_overlay,
25050 ) {
25051 Ok(None) => {}
25052 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25053 Err(error) => panic!(
25054 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25055 error.code,
25056 error.message,
25057 ),
25058 }
25059 assert!(launch_control.clear_native_instance_launch_overlay(
25060 diagnostic_address.session.instance_id,
25061 ));
25062 drop(diagnostic_materialization);
25063 assert!(shared
25064 .materializations
25065 .lock()
25066 .unwrap_or_else(|poisoned| poisoned.into_inner())
25067 .is_empty());
25068
25069 let node_task = tokio::spawn(server.run());
25070 let ready_client = timeout(Duration::from_secs(5), async {
25071 loop {
25072 match gate4agent_node_wire::LocalNodeClient::connect(
25073 &endpoint,
25074 &shared.node_id,
25075 ClientRole::Observer,
25076 "fixture-token",
25077 )
25078 .await
25079 {
25080 Ok(client) => break client,
25081 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25082 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25083 }
25084 }
25085 })
25086 .await
25087 .expect("Kimi context-only Node did not become ready");
25088 assert_eq!(
25089 ready_client
25090 .hello()
25091 .snapshot
25092 .provider_runtime_statuses
25093 .iter()
25094 .find(|status| status.provider().as_str() == "kimi")
25095 .expect("Kimi runtime status is missing")
25096 .mode(),
25097 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25098 );
25099 drop(ready_client);
25100
25101 let receipt = match shared.spawn_from_spec(spec).await {
25102 Ok(receipt) => receipt,
25103 Err(error) => panic!(
25104 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25105 error.code,
25106 error.message,
25107 ),
25108 };
25109 assert_eq!(receipt.provider, agent("kimi"));
25110 assert_eq!(receipt.context.as_ref(), Some(&context));
25111 assert!(receipt.context_binding_is_valid());
25112 timeout(Duration::from_secs(5), async {
25113 while !proof_path.is_file() {
25114 sleep(Duration::from_millis(10)).await;
25115 }
25116 })
25117 .await
25118 .expect("Kimi context-only fixture did not write its proof");
25119
25120 let proof = std::fs::read_to_string(&proof_path).unwrap();
25121 let lines = proof.lines().collect::<Vec<_>>();
25122 assert_eq!(lines.len(), 7);
25123 assert_eq!(lines[0], "context-only");
25124 assert!(Path::new(lines[1]).is_absolute());
25125 assert_ne!(
25126 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25127 std::fs::canonicalize(&workspace_root).unwrap(),
25128 );
25129 assert_eq!(
25130 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25131 std::fs::canonicalize(&workspace_root).unwrap(),
25132 );
25133 assert_eq!(lines[3], expected_context_hash);
25134 assert_eq!(lines[4], "g4a-context-pack-v1");
25135 assert_eq!(lines[5], "codex");
25136 assert_eq!(lines[6], "2");
25137 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25138 session.instance_id == receipt.session.session.instance_id
25139 && session.agent_id.as_str() == "kimi"
25140 }));
25141
25142 shutdown.request_shutdown().await.unwrap();
25143 timeout(Duration::from_secs(5), node_task)
25144 .await
25145 .unwrap()
25146 .unwrap()
25147 .unwrap();
25148 drop(shutdown);
25149 drop(shared);
25150 std::fs::remove_dir_all(root).unwrap();
25151 }
25152
25153 #[cfg(feature = "fixture")]
25154 #[test]
25155 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25156 let root = temporary_workspace_root("clean-exit-constructor");
25157 std::fs::create_dir_all(&root).unwrap();
25158 let config = construction_test_config("clean-exit-constructor")
25159 .with_state_path(root.join("node-state.json"))
25160 .unwrap();
25161 let server = NodeServer::new_clean_exit_fixture(
25162 config,
25163 root.clone(),
25164 root.join("started.marker"),
25165 root.join("release.signal"),
25166 )
25167 .unwrap();
25168 assert_eq!(
25169 server
25170 .shared
25171 .enabled_providers
25172 .iter()
25173 .map(AgentId::as_str)
25174 .collect::<Vec<_>>(),
25175 ["claude"],
25176 );
25177 drop(server);
25178 std::fs::remove_dir_all(root).unwrap();
25179 }
25180
25181 #[test]
25182 fn production_registry_provider_contract_manifest_is_exact() {
25183 let registry = active_registry().unwrap();
25184 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25185 assert_eq!(
25186 providers
25187 .iter()
25188 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25189 .collect::<Vec<_>>(),
25190 vec![
25191 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25192 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25193 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25194 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25195 ]
25196 );
25197 assert_eq!(
25198 adapters
25199 .iter()
25200 .map(|contract| (
25201 contract.provider.as_str(),
25202 contract.family,
25203 contract.adapter_id.as_str(),
25204 contract.revision.as_str(),
25205 ))
25206 .collect::<Vec<_>>(),
25207 vec![
25208 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25209 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25210 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25211 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25212 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25213 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25214 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25215 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25216 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25217 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25218 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25219 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25220 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25221 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25222 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25223 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25224 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25225 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25226 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25227 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25228 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25229 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25230 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25231 ]
25232 );
25233 }
25234
25235 fn construction_test_config(name: &str) -> NodeServerConfig {
25236 let workspace = WorkspaceConfig::new(
25237 WorkspaceId::new("primary").unwrap(),
25238 std::env::current_dir().unwrap(),
25239 )
25240 .unwrap();
25241 NodeServerConfig::new(
25242 format!(r"\\.\pipe\gate4agent-{name}"),
25243 "fixture-token",
25244 NodeId::new(name).unwrap(),
25245 [workspace],
25246 )
25247 .unwrap()
25248 }
25249
25250 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25251 let adapter = AdapterBinding::new(
25252 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25253 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25254 gate4agent_types::AdapterVerification::SyntheticFixture,
25255 )
25256 .unwrap();
25257 let agent_ids = ["claude", "codex", "kimi"]
25258 .into_iter()
25259 .map(|agent_id| AgentId::new(agent_id).unwrap())
25260 .collect::<Vec<_>>();
25261 let adapters = gate4agent_catalog::AdapterRegistry::new(
25262 [
25263 AdapterFamily::PtySemantic,
25264 AdapterFamily::Pipe,
25265 AdapterFamily::OneShot,
25266 AdapterFamily::Acp,
25267 AdapterFamily::Hook,
25268 AdapterFamily::ManagedHook,
25269 AdapterFamily::History,
25270 AdapterFamily::Resume,
25271 AdapterFamily::SessionOptions,
25272 AdapterFamily::CapabilityProbe,
25273 ]
25274 .into_iter()
25275 .map(|family| gate4agent_catalog::AdapterDescriptor {
25276 family,
25277 binding: adapter.clone(),
25278 agents: agent_ids.clone(),
25279 }),
25280 )
25281 .unwrap();
25282 let specs = agent_ids
25283 .iter()
25284 .map(|agent_id| {
25285 let agent_id = agent_id.as_str();
25286 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25287 spec.revision =
25288 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25289 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25290 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25291 adapter: adapter.clone(),
25292 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25293 launch_override: None,
25294 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25295 });
25296 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25297 adapter: adapter.clone(),
25298 launch_override: None,
25299 });
25300 spec.capabilities.adapters.hook = Some(adapter.clone());
25301 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25302 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25303 spec.capabilities.adapters.history = Some(adapter.clone());
25304 spec.capabilities.adapters.resume = Some(adapter.clone());
25305 spec.capabilities.adapters.session_options = Some(adapter.clone());
25306 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25307 spec
25308 })
25309 .collect::<Vec<_>>();
25310 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25311 }
25312
25313 #[test]
25321 fn node_server_construction_accepts_the_production_manifest() {
25322 let registry = active_registry().unwrap();
25323 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25324 assert_eq!(providers.len(), registry.iter().count());
25325 assert!(!providers.is_empty());
25326 let server = NodeServer::new_with_registry(
25327 construction_test_config("manifest-production-bound"),
25328 registry,
25329 )
25330 .unwrap();
25331 drop(server);
25332 }
25333
25334 #[test]
25335 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25336 let registry = maximum_size_provider_manifest_registry();
25337 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25338 assert_eq!(providers.len(), 3);
25339 assert_eq!(adapters.len(), 30);
25340 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25341 let error = match NodeServer::new_with_registry(
25342 construction_test_config("manifest-handshake-overflow"),
25343 registry,
25344 ) {
25345 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25346 Err(error) => error,
25347 };
25348 assert!(matches!(
25349 error,
25350 NodeServerError::ProviderContractManifest(message)
25351 if message.contains("authentication binding length")
25352 && message.contains("8192-byte limit")
25353 ));
25354 }
25355
25356 #[test]
25357 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25358 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25359 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25360 let registry = AgentRegistry::new([spec]).unwrap();
25361 assert!(matches!(
25362 provider_contract_manifest(®istry),
25363 Err(NodeServerError::ProviderContractManifest(message))
25364 if message.contains("exceeds the 128-byte limit")
25365 ));
25366 }
25367
25368 #[test]
25369 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25370 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25371 spec.revision = "invalid revision".to_owned();
25372 let registry = AgentRegistry::new([spec]).unwrap();
25373 assert!(matches!(
25374 provider_contract_manifest(®istry),
25375 Err(NodeServerError::ProviderContractManifest(message))
25376 if message.contains("bounded lowercase ASCII")
25377 ));
25378 }
25379
25380 #[test]
25381 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25382 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25383 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25384 let conflicting_binding = builtin_registry()
25385 .get_by_id("codex")
25386 .unwrap()
25387 .capabilities
25388 .adapters
25389 .one_shot
25390 .clone()
25391 .unwrap();
25392 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25393 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25394 pipe.adapter = transport_binding;
25395 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25396 let registry = AgentRegistry::new([spec]).unwrap();
25397 assert!(matches!(
25398 provider_contract_manifest(®istry),
25399 Err(NodeServerError::ProviderContractManifest(message))
25400 if message.contains("conflicting OneShot adapter bindings")
25401 ));
25402 }
25403
25404 #[test]
25405 fn public_node_failures_expose_only_fixed_categories() {
25406 let provider = failure(
25407 NodeFailureCode::BackendOperationFailed,
25408 r"provider bearer-secret failed at C:\private\session.jsonl",
25409 );
25410 assert_eq!(provider.message, "backend-operation-failed");
25411 assert!(!provider.message.contains("bearer-secret"));
25412 assert!(!provider.message.contains(r"C:\private"));
25413 let persistence = persistence_failure(io::Error::new(
25414 io::ErrorKind::Other,
25415 r"state write failed at C:\private\state-v1.json",
25416 ));
25417 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25418 assert!(!persistence.message.contains(r"C:\private"));
25419
25420 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25421 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25422 assert_eq!(
25423 generic.to_string(),
25424 "node durable state failed: durable-state-load-failed",
25425 );
25426 }
25427 }
25428
25429 #[test]
25430 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25431 let sequence = b"\x1b[1;5D".to_vec();
25432 assert_eq!(
25433 terminal_bytes_action(sequence.clone()).unwrap(),
25434 InputAction::TerminalBytes(sequence),
25435 );
25436 assert_eq!(
25437 terminal_bytes_action(Vec::new()).unwrap_err().code,
25438 NodeFailureCode::InvalidRequest,
25439 );
25440 assert_eq!(
25441 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25442 .unwrap_err()
25443 .code,
25444 NodeFailureCode::InvalidRequest,
25445 );
25446 }
25447
25448 #[test]
25449 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25450 let root = temporary_workspace_root("exclusive-state-owner");
25451 std::fs::create_dir_all(&root).unwrap();
25452 let workspace = WorkspaceConfig::new(
25453 WorkspaceId::new("primary").unwrap(),
25454 &root,
25455 )
25456 .unwrap();
25457 let state_path = root.join("state-v1.json");
25458 let endpoint = format!(
25459 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25460 std::process::id(),
25461 std::time::SystemTime::now()
25462 .duration_since(std::time::UNIX_EPOCH)
25463 .unwrap()
25464 .as_nanos(),
25465 );
25466 let config = NodeServerConfig::new(
25467 endpoint,
25468 "fixture-token",
25469 NodeId::new("node-1").unwrap(),
25470 [workspace],
25471 )
25472 .unwrap()
25473 .with_state_path(&state_path)
25474 .unwrap();
25475
25476 let first = NodeServer::new(config.clone()).unwrap();
25477 let error = match NodeServer::new(config.clone()) {
25478 Ok(_) => panic!("a second node server acquired the same durable state path"),
25479 Err(error) => error,
25480 };
25481 assert!(matches!(
25482 &error,
25483 NodeServerError::DurableState(error)
25484 if error.kind() == io::ErrorKind::WouldBlock
25485 ));
25486 assert_eq!(
25487 error.to_string(),
25488 "node durable state failed: durable-state-lock-failed",
25489 );
25490 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25491 drop(first);
25492 let second = NodeServer::new(config).unwrap();
25493 drop(second);
25494 std::fs::remove_dir_all(root).unwrap();
25495 }
25496
25497 #[test]
25498 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25499 let catalog = active_registry().unwrap();
25500 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25501 let workspace_id = WorkspaceId::new("primary").unwrap();
25502 let workspace = WorkspaceConfig::new(
25503 workspace_id.clone(),
25504 std::env::current_dir().unwrap(),
25505 )
25506 .unwrap();
25507 let shared = NodeShared::new(
25508 handle,
25509 "fixture-token".to_owned(),
25510 NodeId::new("node-1").unwrap(),
25511 vec![workspace.clone()],
25512 vec![agent("claude"), agent("codex"), agent("kimi")],
25513 );
25514 let instance_id = AgentInstanceId(17);
25515 let generation = SessionGeneration(3);
25516 assert!(shared.address_for(instance_id, generation).is_none());
25517 shared.bind_session(instance_id, workspace_id.clone(), generation);
25518 let address = SessionAddress {
25519 workspace_id,
25520 session: SessionKey { instance_id, generation },
25521 };
25522 assert_eq!(
25523 shared.workspace_root(&address.workspace_id).unwrap(),
25524 workspace.canonical_root,
25525 );
25526 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25527 shared.remove_binding(&address);
25528 assert!(shared.address_for(instance_id, generation).is_none());
25529 }
25530
25531 #[test]
25532 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25533 let address = SessionAddress {
25534 workspace_id: WorkspaceId::new("primary").unwrap(),
25535 session: SessionKey {
25536 instance_id: AgentInstanceId(7),
25537 generation: SessionGeneration(2),
25538 },
25539 };
25540 let record_id = SessionRecordId::new("record-a").unwrap();
25541 let size = gate4agent_types::TerminalSize {
25542 rows: 24,
25543 columns: 80,
25544 };
25545 let address_requests = [
25546 NodeRequest::Resume {
25547 session: address.clone(),
25548 terminal_size: size,
25549 initial_prompt: None,
25550 },
25551 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25552 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25553 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25554 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25555 NodeRequest::TerminalControl {
25556 session: address.clone(),
25557 control: TerminalControl::Enter,
25558 },
25559 NodeRequest::Resize { session: address.clone(), size },
25560 NodeRequest::Interrupt { session: address.clone() },
25561 NodeRequest::Stop { session: address.clone(), force: false },
25562 NodeRequest::Remove { session: address },
25563 ];
25564 for request in &address_requests {
25565 assert!(request_requires_open_provider_ids_with(
25566 request,
25567 |_| Some(agent("third-party-agent")),
25568 |_| Some(agent("claude")),
25569 ));
25570 assert!(!request_requires_open_provider_ids_with(
25571 request,
25572 |_| Some(agent("claude")),
25573 |_| Some(agent("claude")),
25574 ));
25575 assert!(request_requires_open_provider_ids_with(
25576 request,
25577 |_| None,
25578 |_| Some(agent("claude")),
25579 ));
25580 }
25581
25582 let record_requests = [
25583 NodeRequest::RenameSessionRecord {
25584 record_id: record_id.clone(),
25585 display_name: "renamed".to_owned(),
25586 },
25587 NodeRequest::ResumeSessionRecord {
25588 record_id: record_id.clone(),
25589 terminal_size: size,
25590 initial_prompt: None,
25591 },
25592 NodeRequest::ForgetSessionRecord { record_id },
25593 ];
25594 for request in &record_requests {
25595 assert!(request_requires_open_provider_ids_with(
25596 request,
25597 |_| Some(agent("claude")),
25598 |_| Some(agent("grok")),
25599 ));
25600 assert!(!request_requires_open_provider_ids_with(
25601 request,
25602 |_| Some(agent("claude")),
25603 |_| Some(agent("kimi")),
25604 ));
25605 assert!(request_requires_open_provider_ids_with(
25606 request,
25607 |_| Some(agent("claude")),
25608 |_| None,
25609 ));
25610 }
25611
25612 assert!(request_requires_open_provider_ids_with(
25613 &NodeRequest::Spawn {
25614 workspace_id: WorkspaceId::new("primary").unwrap(),
25615 provider: agent("third-party-agent"),
25616 mode: SessionMode::Pty,
25617 terminal_size: size,
25618 initial_prompt: None,
25619 },
25620 |_| None,
25621 |_| None,
25622 ));
25623 assert!(!request_requires_open_provider_ids_with(
25624 &NodeRequest::Snapshot,
25625 |_| None,
25626 |_| None,
25627 ));
25628 assert!(request_requires_open_provider_ids_with(
25629 &NodeRequest::ForgetContextPack {
25630 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25631 },
25632 |_| None,
25633 |_| None,
25634 ));
25635 }
25636
25637 #[test]
25638 fn legacy_projection_filters_open_record_replies_and_event_history() {
25639 let catalog = active_registry().unwrap();
25640 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25641 let workspace = WorkspaceConfig::new(
25642 WorkspaceId::new("primary").unwrap(),
25643 std::env::current_dir().unwrap(),
25644 )
25645 .unwrap();
25646 let shared = NodeShared::new(
25647 handle,
25648 "fixture-token".to_owned(),
25649 NodeId::new("node-1").unwrap(),
25650 vec![workspace],
25651 vec![agent("claude"), agent("grok")],
25652 );
25653 let legacy = record("claude", "legacy-record");
25654 let open = record("grok", "open-record");
25655 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25656 record: legacy.clone(),
25657 });
25658 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25659 record: open.clone(),
25660 });
25661 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25662 record_id: legacy.record_id.clone(),
25663 });
25664 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25665 record_id: open.record_id.clone(),
25666 });
25667 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25668 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25669 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25670 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25671
25672 let mut record_reply = ResponseEnvelope {
25673 request_id: 1,
25674 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25675 };
25676 project_response_legacy_provider_ids(&shared, &mut record_reply);
25677 assert!(matches!(
25678 record_reply.result,
25679 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25680 ));
25681
25682 let mut resync = ResponseEnvelope {
25683 request_id: 2,
25684 result: Ok(NodeResponse::Resync {
25685 event_sequence: open_removed.sequence,
25686 oldest_available_sequence: 1,
25687 snapshot: shared.snapshot(),
25688 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25689 }),
25690 };
25691 project_response_legacy_provider_ids(&shared, &mut resync);
25692 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25693 panic!("legacy projection changed the resync response kind");
25694 };
25695 assert!(snapshot.session_records.iter().all(|item| {
25696 provider_id_is_legacy(&item.provider)
25697 }));
25698 assert_eq!(events.len(), 2);
25699 assert!(events.iter().all(|event| matches!(
25700 &event.event,
25701 NodeEvent::SessionRecordUpserted { record }
25702 if provider_id_is_legacy(&record.provider)
25703 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25704 }
25705
25706 #[test]
25707 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25708 let catalog = active_registry().unwrap();
25709 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25710 let workspace_id = WorkspaceId::new("primary").unwrap();
25711 let workspace = WorkspaceConfig::new(
25712 workspace_id.clone(),
25713 std::env::current_dir().unwrap(),
25714 )
25715 .unwrap();
25716 let shared = NodeShared::new(
25717 handle,
25718 "fixture-token".to_owned(),
25719 NodeId::new("node-1").unwrap(),
25720 vec![workspace],
25721 vec![agent("claude")],
25722 );
25723 for index in 0..CONTROL_SESSIONS_MAX {
25724 shared.bind_session(
25725 AgentInstanceId((index + 1) as u64),
25726 workspace_id.clone(),
25727 SessionGeneration(1),
25728 );
25729 }
25730 let error = shared.ensure_binding_capacity().unwrap_err();
25731 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25732 }
25733
25734 #[tokio::test]
25735 async fn provider_spawn_admission_rejects_before_session_mutation() {
25736 let catalog = active_registry().unwrap();
25737 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25738 let workspace = WorkspaceConfig::new(
25739 WorkspaceId::new("primary").unwrap(),
25740 std::env::current_dir().unwrap(),
25741 )
25742 .unwrap();
25743 let mut shared = NodeShared::new(
25744 handle,
25745 "fixture-token".to_owned(),
25746 NodeId::new("node-provider-admission").unwrap(),
25747 vec![workspace],
25748 vec![agent("claude")],
25749 );
25750 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25751 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25752 ])
25753 .unwrap();
25754 let terminal_size = gate4agent_types::TerminalSize {
25755 rows: 24,
25756 columns: 80,
25757 };
25758
25759 let unavailable = shared
25760 .spawn_session(
25761 WorkspaceId::new("primary").unwrap(),
25762 agent("claude"),
25763 SessionMode::Pty,
25764 terminal_size,
25765 None,
25766 )
25767 .await
25768 .unwrap_err();
25769 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25770 assert_eq!(unavailable.message, "backend-operation-failed");
25771
25772 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25773 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25774 agent("claude"),
25775 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25776 ),
25777 ])
25778 .unwrap();
25779 let semantic = shared
25780 .spawn_session(
25781 WorkspaceId::new("primary").unwrap(),
25782 agent("claude"),
25783 SessionMode::Inline,
25784 terminal_size,
25785 None,
25786 )
25787 .await
25788 .unwrap_err();
25789 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25790 assert_eq!(semantic.message, "unsupported-capability");
25791
25792 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25793 assert!(shared
25794 .session_bindings
25795 .lock()
25796 .unwrap_or_else(|poisoned| poisoned.into_inner())
25797 .is_empty());
25798 assert!(shared
25799 .session_records
25800 .lock()
25801 .unwrap_or_else(|poisoned| poisoned.into_inner())
25802 .records
25803 .is_empty());
25804 assert!(shared.handle.snapshot().sessions.is_empty());
25805 }
25806
25807 #[tokio::test]
25808 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25809 let catalog = active_registry().unwrap();
25810 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25811 let workspace_id = WorkspaceId::new("primary").unwrap();
25812 let workspace = WorkspaceConfig::new(
25813 workspace_id.clone(),
25814 std::env::current_dir().unwrap(),
25815 )
25816 .unwrap();
25817 let shared = NodeShared::new(
25818 handle,
25819 "fixture-token".to_owned(),
25820 NodeId::new("node-raw-spawn-record").unwrap(),
25821 vec![workspace],
25822 vec![agent("claude")],
25823 );
25824 let address = SessionAddress {
25825 workspace_id,
25826 session: SessionKey {
25827 instance_id: AgentInstanceId(71),
25828 generation: SessionGeneration::default(),
25829 },
25830 };
25831
25832 let record_id = shared
25833 .bind_spawn_session(
25834 &address,
25835 agent("claude"),
25836 SessionMode::Pty,
25837 ProviderRuntimePolicy::raw_pty(),
25838 SpawnRecordPolicy::Always,
25839 None,
25840 )
25841 .unwrap()
25842 .unwrap();
25843 shared
25844 .handle
25845 .dispatch(shared.prepare_command(ControlCommand::Register {
25846 instance_id: address.session.instance_id,
25847 agent_id: agent("claude"),
25848 transport: TransportKind::Pty,
25849 }))
25850 .unwrap();
25851 runtime.tick().await;
25852
25853 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25854 session.instance_id == address.session.instance_id
25855 && session.generation == address.session.generation
25856 }));
25857 let record = shared.record(&record_id).unwrap();
25858 assert_eq!(record.display_name, "claude #1");
25859 assert_eq!(record.state, ManagedSessionState::Live);
25860 assert_eq!(record.active_session.as_ref(), Some(&address));
25861 assert!(record.provider_session.is_none());
25862 let binding_record_id = shared
25863 .session_bindings
25864 .lock()
25865 .unwrap_or_else(|poisoned| poisoned.into_inner())
25866 .get(&address.session.instance_id)
25867 .and_then(|binding| binding.record_id.clone());
25868 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25869
25870 let renamed = shared
25871 .rename_session_record(&record_id, "raw review".to_owned())
25872 .unwrap();
25873 assert_eq!(renamed.record_id, record_id);
25874 assert_eq!(renamed.display_name, "raw review");
25875 shared.reconcile_managed_record(
25876 &record_id,
25877 &address,
25878 &ControlEvent {
25879 sequence: 1,
25880 command_id: None,
25881 instance_id: address.session.instance_id,
25882 generation: address.session.generation,
25883 event: ControlEventKind::Running { process_id: None },
25884 },
25885 false,
25886 false,
25887 );
25888 let running = shared.record(&record_id).unwrap();
25889 assert_eq!(running.state, ManagedSessionState::Live);
25890 assert_eq!(running.active_session.as_ref(), Some(&address));
25891 assert_eq!(running.display_name, "raw review");
25892
25893 shared.reconcile_managed_record(
25894 &record_id,
25895 &address,
25896 &ControlEvent {
25897 sequence: 2,
25898 command_id: None,
25899 instance_id: address.session.instance_id,
25900 generation: address.session.generation,
25901 event: ControlEventKind::Exited {
25902 exit_code: Some(0),
25903 forced: false,
25904 },
25905 },
25906 false,
25907 false,
25908 );
25909 let detached = shared.record(&record_id).unwrap();
25910 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25911 assert!(detached.active_session.is_none());
25912 assert_eq!(detached.display_name, "raw review");
25913 let renamed_detached = shared
25914 .rename_session_record(&record_id, "raw review detached".to_owned())
25915 .unwrap();
25916 assert_eq!(renamed_detached.record_id, record_id);
25917 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25918 assert!(renamed_detached.active_session.is_none());
25919 assert_eq!(renamed_detached.display_name, "raw review detached");
25920 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25921 }
25922
25923 #[test]
25924 fn legacy_raw_spawn_binding_remains_untracked() {
25925 let catalog = active_registry().unwrap();
25926 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25927 let workspace_id = WorkspaceId::new("primary").unwrap();
25928 let workspace = WorkspaceConfig::new(
25929 workspace_id.clone(),
25930 std::env::current_dir().unwrap(),
25931 )
25932 .unwrap();
25933 let shared = NodeShared::new(
25934 handle,
25935 "fixture-token".to_owned(),
25936 NodeId::new("node-legacy-raw-spawn").unwrap(),
25937 vec![workspace],
25938 vec![agent("claude")],
25939 );
25940 let address = SessionAddress {
25941 workspace_id,
25942 session: SessionKey {
25943 instance_id: AgentInstanceId(72),
25944 generation: SessionGeneration::default(),
25945 },
25946 };
25947
25948 let record_id = shared
25949 .bind_spawn_session(
25950 &address,
25951 agent("claude"),
25952 SessionMode::Pty,
25953 ProviderRuntimePolicy::raw_pty(),
25954 SpawnRecordPolicy::ProviderIdentityOnly,
25955 None,
25956 )
25957 .unwrap();
25958
25959 assert!(record_id.is_none());
25960 let binding_record_id = shared
25961 .session_bindings
25962 .lock()
25963 .unwrap_or_else(|poisoned| poisoned.into_inner())
25964 .get(&address.session.instance_id)
25965 .and_then(|binding| binding.record_id.clone());
25966 assert!(binding_record_id.is_none());
25967 assert!(shared.snapshot().session_records.is_empty());
25968 }
25969
25970 #[tokio::test]
25971 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25972 let catalog = active_registry().unwrap();
25973 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25974 let workspace_id = WorkspaceId::new("primary").unwrap();
25975 let workspace = WorkspaceConfig::new(
25976 workspace_id.clone(),
25977 std::env::current_dir().unwrap(),
25978 )
25979 .unwrap();
25980 let mut shared = NodeShared::new(
25981 handle,
25982 "fixture-token".to_owned(),
25983 NodeId::new("node-raw-semantic-gate").unwrap(),
25984 vec![workspace],
25985 vec![agent("claude")],
25986 );
25987 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25988 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25989 ])
25990 .unwrap();
25991 shared
25992 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25993 .unwrap();
25994 let address = SessionAddress {
25995 workspace_id,
25996 session: SessionKey {
25997 instance_id: AgentInstanceId(72),
25998 generation: SessionGeneration::default(),
25999 },
26000 };
26001 shared.bind_session_with_policy(
26002 &address,
26003 ProviderRuntimePolicy::raw_pty(),
26004 None,
26005 );
26006 shared
26007 .handle
26008 .dispatch(shared.prepare_command(ControlCommand::Register {
26009 instance_id: address.session.instance_id,
26010 agent_id: agent("claude"),
26011 transport: TransportKind::Pty,
26012 }))
26013 .unwrap();
26014 runtime.tick().await;
26015 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
26016 let revision_before = shared.handle.snapshot().revision;
26017 let history_sequence_before = shared.current_sequence();
26018 let terminal_size = gate4agent_types::TerminalSize {
26019 rows: 24,
26020 columns: 80,
26021 };
26022
26023 for request in [
26024 NodeRequest::Prompt {
26025 session: address.clone(),
26026 text: "prompt".to_owned(),
26027 },
26028 NodeRequest::Paste {
26029 session: address.clone(),
26030 text: "paste".to_owned(),
26031 },
26032 NodeRequest::Resume {
26038 session: address.clone(),
26039 terminal_size,
26040 initial_prompt: Some("resume".to_owned()),
26041 },
26042 ] {
26043 let error = process_request_inner(
26044 &shared,
26045 77,
26046 ClientRole::Operator,
26047 request,
26048 )
26049 .await
26050 .unwrap_err();
26051 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26052 }
26053
26054 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26055 assert_eq!(shared.handle.snapshot().revision, revision_before);
26056 assert_eq!(shared.current_sequence(), history_sequence_before);
26057 let bindings = shared
26058 .session_bindings
26059 .lock()
26060 .unwrap_or_else(|poisoned| poisoned.into_inner());
26061 let binding = bindings.get(&address.session.instance_id).unwrap();
26062 assert_eq!(binding.generation, address.session.generation);
26063 assert!(binding.pending_resume.is_none());
26064 assert!(binding.record_id.is_none());
26065 }
26066
26067 #[tokio::test]
26068 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26069 let catalog = active_registry().unwrap();
26070 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26071 let primary = WorkspaceConfig::new(
26072 WorkspaceId::new("primary").unwrap(),
26073 std::env::current_dir().unwrap(),
26074 )
26075 .unwrap();
26076 let shared = NodeShared::new(
26077 handle,
26078 "fixture-token".to_owned(),
26079 NodeId::new("node-1").unwrap(),
26080 vec![primary],
26081 vec![agent("claude")],
26082 );
26083 shared
26084 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26085 .unwrap();
26086 let before = shared.snapshot();
26087 let error = process_request_inner(
26088 &shared,
26089 77,
26090 ClientRole::Operator,
26091 NodeRequest::RegisterWorkspace {
26092 workspace_id: WorkspaceId::new("foreign").unwrap(),
26093 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26094 },
26095 )
26096 .await
26097 .unwrap_err();
26098 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26099 assert_eq!(error.message, "invalid-request");
26100 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26101 }
26102
26103 #[tokio::test]
26104 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26105 let catalog = active_registry().unwrap();
26106 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26107 let primary_id = WorkspaceId::new("primary").unwrap();
26108 let primary = WorkspaceConfig::new(
26109 primary_id.clone(),
26110 std::env::current_dir().unwrap(),
26111 )
26112 .unwrap();
26113 let shared = NodeShared::new(
26114 handle,
26115 "fixture-token".to_owned(),
26116 NodeId::new("node-1").unwrap(),
26117 vec![primary.clone()],
26118 vec![agent("claude")],
26119 );
26120 let secondary_id = WorkspaceId::new("secondary").unwrap();
26121 let secondary = shared
26122 .register_workspace(
26123 secondary_id.clone(),
26124 std::env::temp_dir().to_string_lossy().into_owned(),
26125 )
26126 .await
26127 .unwrap();
26128 assert_eq!(secondary.workspace_id, secondary_id);
26129 assert!(secondary.sessions.is_empty());
26130 assert_eq!(shared.snapshot().workspaces.len(), 2);
26131
26132 let duplicate_id = shared
26133 .register_workspace(
26134 secondary_id.clone(),
26135 primary.canonical_root().to_owned(),
26136 )
26137 .await
26138 .unwrap_err();
26139 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26140 let duplicate_root = shared
26141 .register_workspace(
26142 WorkspaceId::new("duplicate-root").unwrap(),
26143 windows_path_text(&secondary.canonical_root).to_owned(),
26144 )
26145 .await
26146 .unwrap_err();
26147 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26148
26149 let address = SessionAddress {
26150 workspace_id: secondary_id.clone(),
26151 session: SessionKey {
26152 instance_id: AgentInstanceId(41),
26153 generation: SessionGeneration(1),
26154 },
26155 };
26156 shared.bind_session(
26157 address.session.instance_id,
26158 secondary_id.clone(),
26159 address.session.generation,
26160 );
26161 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26162 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26163 shared.remove_binding(&address);
26164 shared.unregister_workspace(&secondary_id).unwrap();
26165 assert_eq!(shared.snapshot().workspaces.len(), 1);
26166
26167 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26168 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26169 let history = shared
26170 .history
26171 .lock()
26172 .unwrap_or_else(|poisoned| poisoned.into_inner());
26173 assert!(matches!(
26174 &history.events[0],
26175 NodeEventEnvelope {
26176 sequence: 1,
26177 event: NodeEvent::WorkspaceAdded { workspace },
26178 } if workspace.workspace_id == secondary_id
26179 ));
26180 assert!(matches!(
26181 &history.events[1],
26182 NodeEventEnvelope {
26183 sequence: 2,
26184 event: NodeEvent::WorkspaceRemoved { workspace_id },
26185 } if workspace_id == &secondary_id
26186 ));
26187 }
26188
26189 #[tokio::test]
26190 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26191 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26192 std::fs::create_dir_all(&secondary_root).unwrap();
26193 let catalog = active_registry().unwrap();
26194 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26195 let primary_id = WorkspaceId::new("primary").unwrap();
26196 let secondary_id = WorkspaceId::new("secondary").unwrap();
26197 let shared = NodeShared::new(
26198 handle,
26199 "fixture-token".to_owned(),
26200 NodeId::new("node-session-reference-index").unwrap(),
26201 vec![
26202 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26203 .unwrap(),
26204 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26205 ],
26206 vec![agent("claude"), agent("codex")],
26207 );
26208 let identity = ProviderSessionIdentity {
26209 key: gate4agent_types::ProviderSessionKey::SessionId,
26210 id: "provider-session-42".to_owned(),
26211 transcript_path: None,
26212 };
26213 let index_request = |workspace_id: WorkspaceId,
26214 provider: AgentId,
26215 identity: ProviderSessionIdentity,
26216 display_name: &str| {
26217 NodeRequest::IndexProviderSession {
26218 workspace_id,
26219 provider,
26220 identity,
26221 display_name: display_name.to_owned(),
26222 }
26223 };
26224 let request = index_request(
26225 primary_id.clone(),
26226 agent("claude"),
26227 identity.clone(),
26228 "release shepherd",
26229 );
26230 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26231 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26232 }));
26233 let observer = process_request_inner(
26234 &shared,
26235 77,
26236 ClientRole::Observer,
26237 request.clone(),
26238 )
26239 .await
26240 .unwrap_err();
26241 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26242 let uncontrolled = process_request_inner(
26243 &shared,
26244 77,
26245 ClientRole::Operator,
26246 request.clone(),
26247 )
26248 .await
26249 .unwrap_err();
26250 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26251 shared
26252 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26253 .unwrap();
26254
26255 let indexed = process_request_inner(
26256 &shared,
26257 77,
26258 ClientRole::Operator,
26259 request.clone(),
26260 )
26261 .await
26262 .unwrap();
26263 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26264 panic!("provider session reference index returned another response");
26265 };
26266 assert_eq!(record.state, ManagedSessionState::Dormant);
26267 assert_eq!(record.mode, SessionMode::Pty);
26268 assert_eq!(record.workspace_id, primary_id);
26269 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26270 assert!(record.active_session.is_none());
26271 assert!(record.environment_profile.is_none());
26272 assert!(record.bundle.is_none());
26273 assert!(record.context.is_none());
26274
26275 let repeated = process_request_inner(
26276 &shared,
26277 77,
26278 ClientRole::Operator,
26279 index_request(
26280 primary_id.clone(),
26281 agent("claude"),
26282 identity.clone(),
26283 "ignored replacement",
26284 ),
26285 )
26286 .await
26287 .unwrap();
26288 assert!(matches!(
26289 repeated,
26290 NodeResponse::ProviderSessionIndexed { record: ref existing }
26291 if existing.record_id == record.record_id
26292 && existing.display_name == "release shepherd"
26293 ));
26294 let conflicting_workspace = process_request_inner(
26295 &shared,
26296 77,
26297 ClientRole::Operator,
26298 index_request(
26299 secondary_id,
26300 agent("claude"),
26301 identity.clone(),
26302 "release shepherd",
26303 ),
26304 )
26305 .await
26306 .unwrap_err();
26307 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26308 let conflicting_provider = process_request_inner(
26309 &shared,
26310 77,
26311 ClientRole::Operator,
26312 index_request(
26313 primary_id.clone(),
26314 agent("codex"),
26315 identity.clone(),
26316 "release shepherd",
26317 ),
26318 )
26319 .await
26320 .unwrap_err();
26321 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26322 let transcript_rejected = process_request_inner(
26323 &shared,
26324 77,
26325 ClientRole::Operator,
26326 index_request(
26327 primary_id,
26328 agent("claude"),
26329 ProviderSessionIdentity {
26330 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26331 ..identity
26332 },
26333 "release shepherd",
26334 ),
26335 )
26336 .await
26337 .unwrap_err();
26338 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26339 let upserts = shared
26340 .history
26341 .lock()
26342 .unwrap_or_else(|poisoned| poisoned.into_inner())
26343 .events
26344 .iter()
26345 .filter(|event| matches!(
26346 &event.event,
26347 NodeEvent::SessionRecordUpserted { record: published }
26348 if published.record_id == record.record_id
26349 ))
26350 .count();
26351 assert_eq!(upserts, 1);
26352 std::fs::remove_dir_all(secondary_root).unwrap();
26353 }
26354
26355 #[tokio::test]
26356 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26357 let fixture_root = temporary_workspace_root("standalone-independent");
26358 let target = fixture_root.join("independent");
26359 std::fs::create_dir_all(&fixture_root).unwrap();
26360 let catalog = active_registry().unwrap();
26361 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26362 let selected = WorkspaceConfig::new(
26363 WorkspaceId::new("primary").unwrap(),
26364 std::env::current_dir().unwrap(),
26365 )
26366 .unwrap();
26367 let selected_root = selected.canonical_root().to_owned();
26368 let shared = NodeShared::new(
26369 handle,
26370 "fixture-token".to_owned(),
26371 NodeId::new("node-standalone").unwrap(),
26372 vec![selected],
26373 vec![agent("claude")],
26374 );
26375 let workspace_id = WorkspaceId::new("independent").unwrap();
26376 let request = NodeRequest::CreateStandaloneWorkspace {
26377 workspace_id: workspace_id.clone(),
26378 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26379 initial_branch: Some("main".to_owned()),
26380 };
26381 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26382 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26383 }));
26384 assert!(!request_uses_unnegotiated_capability(
26385 &request,
26386 &baseline_capabilities().unwrap(),
26387 ));
26388 assert!(request_uses_unnegotiated_capability(&request, &[]));
26389
26390 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26391 .await
26392 .unwrap_err();
26393 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26394 assert!(!target.exists());
26395 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26396 .await
26397 .unwrap_err();
26398 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26399 assert!(!target.exists());
26400 shared
26401 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26402 .unwrap();
26403
26404 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26405 .await
26406 .unwrap();
26407 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26408 panic!("standalone workspace creation returned another response");
26409 };
26410 assert_eq!(workspace.workspace_id, workspace_id);
26411 assert!(target.join(".git").is_dir());
26412 let branch = run_git_bounded(
26413 target.to_str().unwrap(),
26414 &["branch", "--show-current"],
26415 GIT_OUTPUT_MAX_BYTES,
26416 )
26417 .await
26418 .unwrap();
26419 assert!(branch.success);
26420 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26421 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26422 assert_eq!(worktrees.len(), 1);
26423 let listed_root = WorkspaceConfig::new(
26424 WorkspaceId::new("listed-independent").unwrap(),
26425 &worktrees[0].path,
26426 )
26427 .unwrap();
26428 assert!(platform::roots_equal(
26429 listed_root.canonical_root(),
26430 windows_path_text(&workspace.canonical_root),
26431 ));
26432 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26433 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26434 snapshot.workspace_id == workspace_id
26435 && snapshot.canonical_root == workspace.canonical_root
26436 }));
26437 assert!(shared
26438 .history
26439 .lock()
26440 .unwrap_or_else(|poisoned| poisoned.into_inner())
26441 .events
26442 .iter()
26443 .any(|event| matches!(
26444 &event.event,
26445 NodeEvent::WorkspaceAdded { workspace }
26446 if workspace.workspace_id == workspace_id
26447 )));
26448 std::fs::remove_dir_all(fixture_root).unwrap();
26449 }
26450
26451 #[tokio::test]
26452 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26453 let fixture_root = temporary_workspace_root("standalone-compensation");
26454 let created_target = fixture_root.join("created-target");
26455 let existing_target = fixture_root.join("existing-target");
26456 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26457 let invalid_state_path = invalid_state_parent.join("state.json");
26458 std::fs::create_dir_all(&existing_target).unwrap();
26459 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26460 let catalog = active_registry().unwrap();
26461 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26462 let primary = WorkspaceConfig::new(
26463 WorkspaceId::new("primary").unwrap(),
26464 std::env::current_dir().unwrap(),
26465 )
26466 .unwrap();
26467 let mut shared = NodeShared::new(
26468 handle,
26469 "fixture-token".to_owned(),
26470 NodeId::new("node-standalone-compensation").unwrap(),
26471 vec![primary],
26472 vec![agent("claude")],
26473 );
26474 shared.state_path = Some(invalid_state_path);
26475
26476 let created_recovery = shared
26477 .create_standalone_workspace(
26478 WorkspaceId::new("compensated").unwrap(),
26479 created_target.to_string_lossy().into_owned(),
26480 Some("main".to_owned()),
26481 )
26482 .await
26483 .unwrap_err();
26484 assert_eq!(
26485 created_recovery.code,
26486 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26487 );
26488 assert!(created_target.join(".git").is_dir());
26489 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26490 workspace.workspace_id.as_str() != "compensated"
26491 }));
26492
26493 let existing_recovery = shared
26494 .create_standalone_workspace(
26495 WorkspaceId::new("restored").unwrap(),
26496 existing_target.to_string_lossy().into_owned(),
26497 None,
26498 )
26499 .await
26500 .unwrap_err();
26501 assert_eq!(
26502 existing_recovery.code,
26503 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26504 );
26505 assert!(existing_target.is_dir());
26506 assert!(existing_target.join(".git").is_dir());
26507 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26508 workspace.workspace_id.as_str() != "restored"
26509 }));
26510
26511 let registered_empty = fixture_root.join("registered-empty");
26512 std::fs::create_dir(®istered_empty).unwrap();
26513 let catalog = active_registry().unwrap();
26514 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26515 let registered = WorkspaceConfig::new(
26516 WorkspaceId::new("registered-empty").unwrap(),
26517 ®istered_empty,
26518 )
26519 .unwrap();
26520 let duplicate_shared = NodeShared::new(
26521 handle,
26522 "fixture-token".to_owned(),
26523 NodeId::new("node-standalone-duplicate-root").unwrap(),
26524 vec![registered],
26525 vec![agent("claude")],
26526 );
26527 let duplicate = duplicate_shared
26528 .create_standalone_workspace(
26529 WorkspaceId::new("duplicate-root").unwrap(),
26530 registered_empty.to_string_lossy().into_owned(),
26531 None,
26532 )
26533 .await
26534 .unwrap_err();
26535 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26536 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26537 std::fs::remove_dir_all(fixture_root).unwrap();
26538 }
26539
26540 #[test]
26541 fn managed_record_id_collision_preserves_the_existing_record() {
26542 let catalog = active_registry().unwrap();
26543 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26544 let workspace = WorkspaceConfig::new(
26545 WorkspaceId::new("primary").unwrap(),
26546 std::env::current_dir().unwrap(),
26547 )
26548 .unwrap();
26549 let shared = NodeShared::new(
26550 handle,
26551 "fixture-token".to_owned(),
26552 NodeId::new("node-1").unwrap(),
26553 vec![workspace.clone()],
26554 vec![agent("claude")],
26555 );
26556 let record_id = SessionRecordId::new("sr-collision").unwrap();
26557 let original = ManagedSessionRecord {
26558 record_id: record_id.clone(),
26559 display_name: "original".to_owned(),
26560 provider: agent("claude"),
26561 mode: SessionMode::Pty,
26562 state: ManagedSessionState::Dormant,
26563 workspace_id: workspace.workspace_id,
26564 canonical_root: opaque_windows_path(workspace.canonical_root),
26565 provider_session: None,
26566 active_session: None,
26567 environment_profile: None,
26568 bundle: None,
26569 context_id: None,
26570 context: None,
26571 exported_context: None,
26572 task_binding: None,
26573 created_at_unix_ms: 1,
26574 updated_at_unix_ms: 1,
26575 last_error: None,
26576 };
26577 shared.insert_record(original.clone()).unwrap();
26578 let mut collision = original.clone();
26579 collision.display_name = "replacement".to_owned();
26580 let error = shared.insert_record(collision).unwrap_err();
26581 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26582 assert_eq!(shared.record(&record_id).unwrap(), original);
26583 }
26584
26585 #[test]
26586 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26587 let catalog = active_registry().unwrap();
26588 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26589 let workspace = WorkspaceConfig::new(
26590 WorkspaceId::new("primary").unwrap(),
26591 std::env::current_dir().unwrap(),
26592 )
26593 .unwrap();
26594 let shared = NodeShared::new(
26595 handle,
26596 "fixture-token".to_owned(),
26597 NodeId::new("node-1").unwrap(),
26598 vec![workspace.clone()],
26599 vec![agent("claude")],
26600 );
26601 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26602 shared
26603 .insert_record(ManagedSessionRecord {
26604 record_id: record_id.clone(),
26605 display_name: "runtime error".to_owned(),
26606 provider: agent("claude"),
26607 mode: SessionMode::Pty,
26608 state: ManagedSessionState::IdentityPending,
26609 workspace_id: workspace.workspace_id,
26610 canonical_root: opaque_windows_path(workspace.canonical_root),
26611 provider_session: None,
26612 active_session: None,
26613 environment_profile: None,
26614 bundle: None,
26615 context_id: None,
26616 context: None,
26617 exported_context: None,
26618 task_binding: None,
26619 created_at_unix_ms: 1,
26620 updated_at_unix_ms: 1,
26621 last_error: None,
26622 })
26623 .unwrap();
26624
26625 shared
26626 .mark_record_error(
26627 &record_id,
26628 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26629 )
26630 .unwrap();
26631 let record = shared.record(&record_id).unwrap();
26632 assert_eq!(record.state, ManagedSessionState::Unavailable);
26633 assert_eq!(
26634 record.last_error.as_deref(),
26635 Some("provider-runtime-failed"),
26636 );
26637 let snapshot = shared.snapshot();
26638 assert_eq!(snapshot.session_records, vec![record]);
26639 }
26640
26641 #[test]
26650 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26651 let catalog = active_registry().unwrap();
26652 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26653 let workspace = WorkspaceConfig::new(
26654 WorkspaceId::new("primary").unwrap(),
26655 std::env::current_dir().unwrap(),
26656 )
26657 .unwrap();
26658 let shared = NodeShared::new(
26659 handle,
26660 "fixture-token".to_owned(),
26661 NodeId::new("node-1").unwrap(),
26662 vec![workspace],
26663 vec![agent("claude")],
26664 );
26665
26666 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26667
26668 shared.record_rejected_command(
26669 CommandId(1),
26670 "agent 'claude' does not support transport Acp".to_owned(),
26671 true,
26672 );
26673 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26675
26676 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26677 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26678 assert!(reason.unsupported_transport);
26679
26680 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26682
26683 shared.record_rejected_command(
26688 CommandId(3),
26689 "backend busy".to_owned(),
26690 false,
26691 );
26692 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26693 assert!(!unclassified.unsupported_transport);
26694 }
26695
26696 #[test]
26697 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26698 let catalog = active_registry().unwrap();
26699 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26700 let workspace = WorkspaceConfig::new(
26701 WorkspaceId::new("primary").unwrap(),
26702 std::env::current_dir().unwrap(),
26703 )
26704 .unwrap();
26705 let shared = NodeShared::new(
26706 handle,
26707 "fixture-token".to_owned(),
26708 NodeId::new("node-identity-policy").unwrap(),
26709 vec![workspace.clone()],
26710 vec![agent("claude")],
26711 );
26712 let address = SessionAddress {
26713 workspace_id: workspace.workspace_id.clone(),
26714 session: SessionKey {
26715 instance_id: AgentInstanceId(90),
26716 generation: SessionGeneration(1),
26717 },
26718 };
26719 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26720 shared
26721 .insert_record(ManagedSessionRecord {
26722 record_id: record_id.clone(),
26723 display_name: "policy denied".to_owned(),
26724 provider: agent("claude"),
26725 mode: SessionMode::Pty,
26726 state: ManagedSessionState::IdentityPending,
26727 workspace_id: workspace.workspace_id,
26728 canonical_root: opaque_windows_path(workspace.canonical_root),
26729 provider_session: None,
26730 active_session: Some(address.clone()),
26731 environment_profile: None,
26732 bundle: None,
26733 context_id: None,
26734 context: None,
26735 exported_context: None,
26736 task_binding: None,
26737 created_at_unix_ms: 1,
26738 updated_at_unix_ms: 1,
26739 last_error: None,
26740 })
26741 .unwrap();
26742 let event = ControlEvent {
26743 sequence: 1,
26744 command_id: None,
26745 instance_id: address.session.instance_id,
26746 generation: address.session.generation,
26747 event: ControlEventKind::ProviderEvent {
26748 sequence: 1,
26749 source: gate4agent_types::ProviderSource {
26750 family: AdapterFamily::PtySemantic,
26751 binding: AdapterBinding::new(
26752 gate4agent_types::AdapterId::new("claude").unwrap(),
26753 "fixture/v1",
26754 gate4agent_types::AdapterVerification::SyntheticFixture,
26755 )
26756 .unwrap(),
26757 },
26758 source_sequence: 1,
26759 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26760 identity: gate4agent_types::ProviderSessionIdentity {
26761 key: gate4agent_types::ProviderSessionKey::SessionId,
26762 id: "provider-session".to_owned(),
26763 transcript_path: None,
26764 },
26765 },
26766 },
26767 };
26768
26769 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26770
26771 let record = shared.record(&record_id).unwrap();
26772 assert!(record.provider_session.is_none());
26773 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26774 assert_eq!(record.active_session.as_ref(), Some(&address));
26775 }
26776
26777 #[test]
26778 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26779 let secondary_root = temporary_workspace_root("identity-scope");
26780 std::fs::create_dir_all(&secondary_root).unwrap();
26781 let catalog = active_registry().unwrap();
26782 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26783 let primary = WorkspaceConfig::new(
26784 WorkspaceId::new("primary").unwrap(),
26785 std::env::current_dir().unwrap(),
26786 )
26787 .unwrap();
26788 let secondary = WorkspaceConfig::new(
26789 WorkspaceId::new("secondary").unwrap(),
26790 &secondary_root,
26791 )
26792 .unwrap();
26793 let shared = NodeShared::new(
26794 handle,
26795 "fixture-token".to_owned(),
26796 NodeId::new("node-1").unwrap(),
26797 vec![primary.clone(), secondary.clone()],
26798 vec![agent("claude")],
26799 );
26800 let identity = gate4agent_types::ProviderSessionIdentity {
26801 key: gate4agent_types::ProviderSessionKey::SessionId,
26802 id: "provider-session".to_owned(),
26803 transcript_path: None,
26804 };
26805 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26806 shared
26807 .insert_record(ManagedSessionRecord {
26808 record_id: dormant_id.clone(),
26809 display_name: "dormant".to_owned(),
26810 provider: agent("claude"),
26811 mode: SessionMode::Pty,
26812 state: ManagedSessionState::Dormant,
26813 workspace_id: primary.workspace_id,
26814 canonical_root: opaque_windows_path(primary.canonical_root),
26815 provider_session: Some(identity.clone()),
26816 active_session: None,
26817 environment_profile: None,
26818 bundle: None,
26819 context_id: None,
26820 context: None,
26821 exported_context: None,
26822 task_binding: None,
26823 created_at_unix_ms: 1,
26824 updated_at_unix_ms: 1,
26825 last_error: None,
26826 })
26827 .unwrap();
26828 let address = SessionAddress {
26829 workspace_id: secondary.workspace_id.clone(),
26830 session: SessionKey {
26831 instance_id: AgentInstanceId(91),
26832 generation: SessionGeneration(1),
26833 },
26834 };
26835 let current_id = SessionRecordId::new("sr-current").unwrap();
26836 shared
26837 .insert_record(ManagedSessionRecord {
26838 record_id: current_id.clone(),
26839 display_name: "current".to_owned(),
26840 provider: agent("claude"),
26841 mode: SessionMode::Pty,
26842 state: ManagedSessionState::IdentityPending,
26843 workspace_id: secondary.workspace_id,
26844 canonical_root: opaque_windows_path(secondary.canonical_root),
26845 provider_session: None,
26846 active_session: Some(address.clone()),
26847 environment_profile: None,
26848 bundle: None,
26849 context_id: None,
26850 context: None,
26851 exported_context: None,
26852 task_binding: None,
26853 created_at_unix_ms: 2,
26854 updated_at_unix_ms: 2,
26855 last_error: None,
26856 })
26857 .unwrap();
26858 shared.bind_managed_session(
26859 &address,
26860 current_id.clone(),
26861 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26862 None,
26863 );
26864 let event = ControlEvent {
26865 sequence: 1,
26866 command_id: None,
26867 instance_id: address.session.instance_id,
26868 generation: address.session.generation,
26869 event: ControlEventKind::ProviderEvent {
26870 sequence: 1,
26871 source: gate4agent_types::ProviderSource {
26872 family: gate4agent_types::AdapterFamily::PtySemantic,
26873 binding: gate4agent_types::AdapterBinding::new(
26874 gate4agent_types::AdapterId::new("claude").unwrap(),
26875 "fixture/v1",
26876 gate4agent_types::AdapterVerification::SyntheticFixture,
26877 )
26878 .unwrap(),
26879 },
26880 source_sequence: 1,
26881 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26882 identity,
26883 },
26884 },
26885 };
26886 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26887
26888 let binding = shared
26889 .session_bindings
26890 .lock()
26891 .unwrap_or_else(|poisoned| poisoned.into_inner())
26892 .get(&address.session.instance_id)
26893 .cloned()
26894 .unwrap();
26895 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26896 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26897 let current = shared.record(¤t_id).unwrap();
26898 assert_eq!(current.state, ManagedSessionState::Unavailable);
26899 assert_eq!(current.active_session.as_ref(), Some(&address));
26900 assert_eq!(
26901 current.last_error.as_deref(),
26902 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26903 );
26904 std::fs::remove_dir_all(secondary_root).unwrap();
26905 }
26906
26907 #[tokio::test]
26908 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26909 let root = temporary_workspace_root("semantic-provider-session");
26910 std::fs::create_dir_all(&root).unwrap();
26911 let state_path = root.join("state-v1.json");
26912 let catalog = active_registry().unwrap();
26913 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26914 let workspace = WorkspaceConfig::new(
26915 WorkspaceId::new("primary").unwrap(),
26916 &root,
26917 )
26918 .unwrap();
26919 let node_id = NodeId::new("node-semantic-identity").unwrap();
26920 let shared = NodeShared::new_with_incarnation(
26921 handle,
26922 "fixture-token".to_owned(),
26923 node_id.clone(),
26924 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26925 vec![workspace.clone()],
26926 vec![agent("claude")],
26927 ProviderRuntimeStatuses::default(),
26928 None,
26929 Vec::new(),
26930 Vec::new(),
26931 SpawnProfileRegistry::default(),
26932 None,
26933 None,
26934 None,
26935 Some(state_path.clone()),
26936 Vec::new(),
26937 Vec::new(),
26938 Vec::new(),
26939 Vec::new(),
26940 None,
26941 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26942 );
26943 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26944 key: gate4agent_types::ProviderSessionKey::SessionId,
26945 id: "provider-session".to_owned(),
26946 transcript_path: None,
26947 };
26948 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26949 shared
26950 .insert_record(ManagedSessionRecord {
26951 record_id: durable_id.clone(),
26952 display_name: "durable".to_owned(),
26953 provider: agent("claude"),
26954 mode: SessionMode::Pty,
26955 state: ManagedSessionState::Dormant,
26956 workspace_id: workspace.workspace_id.clone(),
26957 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26958 provider_session: Some(persisted_identity.clone()),
26959 active_session: None,
26960 environment_profile: None,
26961 bundle: None,
26962 context_id: None,
26963 context: None,
26964 exported_context: None,
26965 task_binding: None,
26966 created_at_unix_ms: 1,
26967 updated_at_unix_ms: 1,
26968 last_error: None,
26969 })
26970 .unwrap();
26971 let address = SessionAddress {
26972 workspace_id: workspace.workspace_id.clone(),
26973 session: SessionKey {
26974 instance_id: AgentInstanceId(92),
26975 generation: SessionGeneration(2),
26976 },
26977 };
26978 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26979 shared
26980 .insert_record(ManagedSessionRecord {
26981 record_id: pending_id.clone(),
26982 display_name: "pending".to_owned(),
26983 provider: agent("claude"),
26984 mode: SessionMode::Pty,
26985 state: ManagedSessionState::IdentityPending,
26986 workspace_id: workspace.workspace_id.clone(),
26987 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26988 provider_session: None,
26989 active_session: Some(address.clone()),
26990 environment_profile: None,
26991 bundle: None,
26992 context_id: None,
26993 context: None,
26994 exported_context: None,
26995 task_binding: None,
26996 created_at_unix_ms: 2,
26997 updated_at_unix_ms: 2,
26998 last_error: None,
26999 })
27000 .unwrap();
27001 shared.bind_managed_session(
27002 &address,
27003 pending_id.clone(),
27004 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27005 None,
27006 );
27007 let observed_identity = gate4agent_types::ProviderSessionIdentity {
27008 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
27009 ..persisted_identity
27010 };
27011 let event = ControlEvent {
27012 sequence: 1,
27013 command_id: None,
27014 instance_id: address.session.instance_id,
27015 generation: address.session.generation,
27016 event: ControlEventKind::ProviderEvent {
27017 sequence: 1,
27018 source: gate4agent_types::ProviderSource {
27019 family: gate4agent_types::AdapterFamily::PtySemantic,
27020 binding: gate4agent_types::AdapterBinding::new(
27021 gate4agent_types::AdapterId::new("claude").unwrap(),
27022 "fixture/v1",
27023 gate4agent_types::AdapterVerification::SyntheticFixture,
27024 )
27025 .unwrap(),
27026 },
27027 source_sequence: 1,
27028 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27029 identity: observed_identity.clone(),
27030 },
27031 },
27032 };
27033 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27034
27035 let snapshot = shared.snapshot();
27036 assert_eq!(snapshot.session_records.len(), 1);
27037 let live = &snapshot.session_records[0];
27038 assert_eq!(live.record_id, durable_id);
27039 assert_eq!(live.state, ManagedSessionState::Live);
27040 assert_eq!(live.active_session.as_ref(), Some(&address));
27041 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27042 let binding = shared
27043 .session_bindings
27044 .lock()
27045 .unwrap_or_else(|poisoned| poisoned.into_inner())
27046 .get(&address.session.instance_id)
27047 .cloned()
27048 .unwrap();
27049 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27050
27051 let mut duplicate = live.clone();
27052 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27053 duplicate.display_name = "duplicate".to_owned();
27054 duplicate.state = ManagedSessionState::Dormant;
27055 duplicate.active_session = None;
27056 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27057 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27058 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27059 assert_eq!(shared.snapshot().session_records.len(), 1);
27060
27061 let terminal_size = gate4agent_types::TerminalSize {
27062 rows: 24,
27063 columns: 80,
27064 };
27065 let (first_busy, second_busy) = tokio::join!(
27066 shared.resume_session_record(&durable_id, terminal_size, None),
27067 shared.resume_session_record(&durable_id, terminal_size, None),
27068 );
27069 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27070 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27071 assert_eq!(busy.message, "session-record-busy");
27072 }
27073
27074 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27075 assert_eq!(loaded.records.len(), 1);
27076 assert_eq!(loaded.records[0].record_id, durable_id);
27077 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27078 assert_eq!(
27079 loaded.records[0]
27080 .provider_session
27081 .as_ref()
27082 .unwrap()
27083 .transcript_path,
27084 None,
27085 );
27086 let catalog = active_registry().unwrap();
27087 let (reloaded_handle, _reloaded_runtime) =
27088 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27089 let reloaded = NodeShared::new_with_incarnation(
27090 reloaded_handle,
27091 "fixture-token".to_owned(),
27092 node_id,
27093 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27094 vec![workspace],
27095 vec![agent("claude")],
27096 ProviderRuntimeStatuses::default(),
27097 None,
27098 Vec::new(),
27099 Vec::new(),
27100 SpawnProfileRegistry::default(),
27101 None,
27102 None,
27103 None,
27104 None,
27105 loaded.records,
27106 loaded.managed_worktrees,
27107 loaded.managed_worktree_tombstones,
27108 loaded.materializations,
27109 loaded.warning,
27110 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27111 );
27112 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27113 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27114 std::fs::remove_dir_all(root).unwrap();
27115 }
27116
27117 #[tokio::test]
27118 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27119 let secondary_root = temporary_workspace_root("workspace-restore");
27120 let other_root = temporary_workspace_root("workspace-conflict");
27121 std::fs::create_dir_all(&secondary_root).unwrap();
27122 std::fs::create_dir_all(&other_root).unwrap();
27123 let catalog = active_registry().unwrap();
27124 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27125 let primary = WorkspaceConfig::new(
27126 WorkspaceId::new("primary").unwrap(),
27127 std::env::current_dir().unwrap(),
27128 )
27129 .unwrap();
27130 let secondary = WorkspaceConfig::new(
27131 WorkspaceId::new("secondary").unwrap(),
27132 &secondary_root,
27133 )
27134 .unwrap();
27135 let shared = NodeShared::new(
27136 handle,
27137 "fixture-token".to_owned(),
27138 NodeId::new("node-1").unwrap(),
27139 vec![primary, secondary.clone()],
27140 vec![agent("claude")],
27141 );
27142 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27143 shared
27144 .insert_record(ManagedSessionRecord {
27145 record_id: record_id.clone(),
27146 display_name: "workspace record".to_owned(),
27147 provider: agent("claude"),
27148 mode: SessionMode::Pty,
27149 state: ManagedSessionState::Dormant,
27150 workspace_id: secondary.workspace_id.clone(),
27151 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27152 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27153 key: gate4agent_types::ProviderSessionKey::SessionId,
27154 id: "provider-session".to_owned(),
27155 transcript_path: None,
27156 }),
27157 active_session: None,
27158 environment_profile: None,
27159 bundle: None,
27160 context_id: None,
27161 context: None,
27162 exported_context: None,
27163 task_binding: None,
27164 created_at_unix_ms: 1,
27165 updated_at_unix_ms: 1,
27166 last_error: None,
27167 })
27168 .unwrap();
27169 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27170 let unavailable = shared.record(&record_id).unwrap();
27171 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27172 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27173
27174 let conflict = shared
27175 .register_workspace(
27176 secondary.workspace_id.clone(),
27177 other_root.to_string_lossy().into_owned(),
27178 )
27179 .await
27180 .unwrap_err();
27181 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27182 shared
27183 .register_workspace(
27184 secondary.workspace_id,
27185 secondary_root.to_string_lossy().into_owned(),
27186 )
27187 .await
27188 .unwrap();
27189 let restored = shared.record(&record_id).unwrap();
27190 assert_eq!(restored.state, ManagedSessionState::Dormant);
27191 assert!(restored.last_error.is_none());
27192 std::fs::remove_dir_all(secondary_root).unwrap();
27193 std::fs::remove_dir_all(other_root).unwrap();
27194 }
27195
27196 #[test]
27197 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27198 let error = validate_workspace_request_root(
27199 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27200 )
27201 .unwrap_err();
27202 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27203 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27204 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27205 }
27206
27207 #[test]
27208 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27209 let catalog = active_registry().unwrap();
27210 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27211 let workspace = WorkspaceConfig::new(
27212 WorkspaceId::new("primary").unwrap(),
27213 std::env::current_dir().unwrap(),
27214 )
27215 .unwrap();
27216 let shared = NodeShared::new(
27217 handle,
27218 "fixture-token".to_owned(),
27219 NodeId::new("node-1").unwrap(),
27220 vec![workspace],
27221 vec![agent("claude")],
27222 );
27223 let publishers = 4_u64;
27224 let events_per_publisher = 256_u64;
27225 let start = std::sync::Barrier::new(publishers as usize + 1);
27226 let finished = AtomicU64::new(0);
27227
27228 std::thread::scope(|scope| {
27229 for _ in 0..publishers {
27230 scope.spawn(|| {
27231 start.wait();
27232 for _ in 0..events_per_publisher {
27233 shared.publish(NodeEvent::ControllerChanged { controller: None });
27234 }
27235 finished.fetch_add(1, Ordering::Release);
27236 });
27237 }
27238 start.wait();
27239 while finished.load(Ordering::Acquire) != publishers {
27240 assert_resync_history_is_linearized(shared.resync(0));
27241 std::thread::yield_now();
27242 }
27243 });
27244 let response = shared.resync(0);
27245 assert_resync_history_is_linearized(response.clone());
27246 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27247 unreachable!("resync helper returned another response");
27248 };
27249 assert_eq!(event_sequence, publishers * events_per_publisher);
27250 assert_eq!(events.len() as u64, event_sequence);
27251 }
27252
27253 fn assert_resync_history_is_linearized(response: NodeResponse) {
27254 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27255 unreachable!("resync helper returned another response");
27256 };
27257 assert!(events.windows(2).all(|pair| {
27258 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27259 }));
27260 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27261 }
27262
27263 #[test]
27264 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27265 assert_eq!(
27266 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27267 r"C:\repo\worktree",
27268 );
27269 assert_eq!(
27270 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27271 r"\\server\share\repo",
27272 );
27273 assert!(validate_workspace_root(
27274 &WorkspaceId::new("network").unwrap(),
27275 r"\\server\share\repo",
27276 )
27277 .is_err());
27278 let workspace = WorkspaceConfig::new(
27279 WorkspaceId::new("primary").unwrap(),
27280 std::env::current_dir().unwrap(),
27281 )
27282 .unwrap();
27283 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27284 }
27285
27286 #[test]
27287 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27288 let catalog = active_registry().unwrap();
27289 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27290 let workspace_id = WorkspaceId::new("primary").unwrap();
27291 let workspace = WorkspaceConfig::new(
27292 workspace_id.clone(),
27293 std::env::current_dir().unwrap(),
27294 )
27295 .unwrap();
27296 let shared = NodeShared::new(
27297 handle,
27298 "fixture-token".to_owned(),
27299 NodeId::new("node-1").unwrap(),
27300 vec![workspace],
27301 vec![agent("claude")],
27302 );
27303 let instance_id = AgentInstanceId(17);
27304 let original = SessionAddress {
27305 workspace_id,
27306 session: SessionKey {
27307 instance_id,
27308 generation: SessionGeneration(3),
27309 },
27310 };
27311 shared.bind_session(
27312 instance_id,
27313 original.workspace_id.clone(),
27314 original.session.generation,
27315 );
27316 shared
27317 .arm_resume(
27318 &original,
27319 CommandId(41),
27320 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27321 )
27322 .unwrap();
27323
27324 shared.publish_control(ControlEvent {
27325 sequence: 1,
27326 command_id: None,
27327 instance_id,
27328 generation: SessionGeneration(4),
27329 event: ControlEventKind::Running { process_id: None },
27330 });
27331 assert_eq!(
27332 shared.address_for(instance_id, SessionGeneration(3)),
27333 Some(original.clone()),
27334 );
27335 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27336
27337 shared.publish_control(ControlEvent {
27338 sequence: 2,
27339 command_id: Some(CommandId(42)),
27340 instance_id,
27341 generation: SessionGeneration(3),
27342 event: ControlEventKind::CommandRejected {
27343 message: "unrelated command".to_owned(),
27344 },
27345 });
27346
27347 shared.publish_control(ControlEvent {
27348 sequence: 3,
27349 command_id: None,
27350 instance_id,
27351 generation: SessionGeneration(4),
27352 event: ControlEventKind::ResumeAuthorized {
27353 session: gate4agent_types::ResumeSessionSummary {
27354 key: gate4agent_types::ProviderSessionKey::SessionId,
27355 id: "fixture-session".to_owned(),
27356 },
27357 },
27358 });
27359 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27360 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27361 assert_eq!(rebound.workspace_id, original.workspace_id);
27362 let history = shared
27363 .history
27364 .lock()
27365 .unwrap_or_else(|poisoned| poisoned.into_inner());
27366 assert!(history.events.iter().any(|envelope| matches!(
27367 &envelope.event,
27368 NodeEvent::Control { address, event }
27369 if address == &rebound
27370 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27371 )));
27372 }
27373
27374 #[test]
27375 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27376 for agent in ["claude", "codex", "kimi"] {
27377 assert_eq!(
27378 prompt_framing(&AgentId::new(agent).unwrap()),
27379 PromptFraming::BracketedPaste
27380 );
27381 }
27382 }
27383
27384 fn generous_walk_deadline() -> Instant {
27385 Instant::now() + Duration::from_secs(30)
27386 }
27387
27388 #[test]
27389 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27390 let root = temporary_workspace_root("tree");
27391 std::fs::create_dir_all(root.join("src")).unwrap();
27392 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27393 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27394 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27395 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27396 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27397 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27403 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27404 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27408 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27409
27410 let (entries, truncated, _budget) =
27411 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27412 assert!(!truncated);
27413 assert!(entries.iter().any(|entry| {
27414 entry.relative_path.as_utf8() == Some("src")
27415 && entry.kind == WorkspaceEntryKind::Directory
27416 }));
27417 assert!(entries.iter().any(|entry| {
27418 entry.relative_path.as_utf8() == Some("src/lib.rs")
27419 && entry.kind == WorkspaceEntryKind::File
27420 }));
27421 assert!(entries.iter().any(|entry| {
27422 entry.relative_path.as_utf8() == Some("target-notes")
27423 && entry.kind == WorkspaceEntryKind::Directory
27424 }));
27425 assert!(entries.iter().any(|entry| {
27426 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27427 && entry.kind == WorkspaceEntryKind::File
27428 }));
27429 assert!(entries.iter().all(|entry| {
27430 let path = entry.relative_path.as_utf8().unwrap();
27431 !path.starts_with(".git")
27432 && !(path == "target" || path.starts_with("target/"))
27433 && !(path == "target-a2" || path.starts_with("target-a2/"))
27434 && !path.starts_with("node_modules")
27435 }));
27436
27437 std::fs::remove_dir_all(&root).unwrap();
27438 }
27439
27440 #[test]
27441 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27442 let root = temporary_workspace_root("tree-entry-cap");
27443 std::fs::create_dir_all(&root).unwrap();
27444 for index in 0..20 {
27445 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27446 }
27447
27448 let (entries, truncated, budget) =
27449 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27450 assert!(truncated);
27451 assert!(budget.entry_cap_exceeded);
27452 assert!(!budget.time_budget_exceeded);
27453 assert!(entries.len() <= 5);
27454
27455 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27456 .expect("an entry-cap-truncated walk must report a truncation marker");
27457 assert!(inspection_truncation.walk_entry_cap_exceeded);
27458 assert!(!inspection_truncation.walk_time_budget_exceeded);
27459 assert!(!inspection_truncation.git_time_budget_exceeded);
27460 assert!(inspection_truncation.entries_visited >= 5);
27461
27462 std::fs::remove_dir_all(&root).unwrap();
27463 }
27464
27465 #[test]
27466 fn workspace_walk_within_budget_reports_no_truncation() {
27467 let root = temporary_workspace_root("tree-no-truncation");
27468 std::fs::create_dir_all(&root).unwrap();
27469 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27470
27471 let (_entries, truncated, budget) =
27472 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27473 assert!(!truncated);
27474 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27475
27476 std::fs::remove_dir_all(&root).unwrap();
27477 }
27478
27479 #[test]
27480 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27481 let root = temporary_workspace_root("tree-prefix-siblings");
27482 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27483 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27484 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27485 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27486
27487 let (entries, truncated, _budget) =
27488 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27489 assert!(!truncated);
27490 let paths = entries
27491 .iter()
27492 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27493 .collect::<Vec<_>>();
27494 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27495 assert_eq!(
27496 paths,
27497 vec![
27498 "gate4agent-c2".to_owned(),
27499 "gate4agent-c2-client".to_owned(),
27500 "gate4agent-c2-client/lib.rs".to_owned(),
27501 "gate4agent-c2/src".to_owned(),
27502 "gate4agent-c2/src/lib.rs".to_owned(),
27503 ],
27504 );
27505
27506 std::fs::remove_dir_all(&root).unwrap();
27507 }
27508
27509 #[test]
27510 fn git_parsers_cap_status_and_commit_results() {
27511 let mut status_output = Vec::new();
27512 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27513 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27514 }
27515 let mut snapshot = git_snapshot_for_parser();
27516 parse_git_status(&status_output, &mut snapshot);
27517 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27518 assert!(snapshot.truncated);
27519 assert_eq!(snapshot.status[0].index_status, " ");
27520 assert_eq!(snapshot.status[0].worktree_status, "M");
27521
27522 snapshot.truncated = false;
27523 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27524 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27525 .collect::<String>();
27526 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27527 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27528 assert!(snapshot.truncated);
27529 }
27530
27531 #[test]
27532 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27533 let mut snapshot = git_snapshot_for_parser();
27534 parse_git_status(
27535 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27536 &mut snapshot,
27537 );
27538
27539 assert!(!snapshot.truncated);
27540 assert_eq!(snapshot.status.len(), 3);
27541 assert_eq!(snapshot.status[0].index_status, "R");
27542 assert_eq!(snapshot.status[0].worktree_status, " ");
27543 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27544 assert_eq!(
27545 snapshot.status[0]
27546 .previous_path
27547 .as_ref()
27548 .and_then(RepositoryPath::as_utf8),
27549 Some("src/previous name.rs"),
27550 );
27551 assert_eq!(snapshot.status[1].index_status, " ");
27552 assert_eq!(snapshot.status[1].worktree_status, "C");
27553 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27554 assert_eq!(
27555 snapshot.status[1]
27556 .previous_path
27557 .as_ref()
27558 .and_then(RepositoryPath::as_utf8),
27559 Some("src/original.rs"),
27560 );
27561 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27562 assert!(snapshot.status[2].previous_path.is_none());
27563 }
27564
27565 #[test]
27566 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27567 let mut snapshot = git_snapshot_for_parser();
27568 parse_git_status(
27569 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27570 &mut snapshot,
27571 );
27572
27573 assert!(snapshot.truncated);
27574 assert_eq!(snapshot.status.len(), 1);
27575 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27576 assert!(snapshot.status[0].previous_path.is_none());
27577 }
27578
27579 #[tokio::test]
27580 async fn observer_can_browse_host_directories_without_controller() {
27581 let root = temporary_workspace_root("host-directory-observer");
27582 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27583 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27584 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27585 let workspace_id = WorkspaceId::new("primary").unwrap();
27586 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27587 let catalog = active_registry().unwrap();
27588 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27589 let shared = NodeShared::new(
27590 handle,
27591 "fixture-token".to_owned(),
27592 NodeId::new("node-1").unwrap(),
27593 vec![workspace],
27594 vec![agent("claude")],
27595 );
27596 let request = NodeRequest::BrowseHostDirectories {
27597 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27598 after: None,
27599 };
27600 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27601 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27602 }));
27603 assert!(!request_uses_unnegotiated_capability(
27604 &request,
27605 &baseline_capabilities().unwrap(),
27606 ));
27607 assert!(request_uses_unnegotiated_capability(&request, &[]));
27608
27609 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27610 .await
27611 .unwrap();
27612 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27613 panic!("host directory browse returned another response");
27614 };
27615 assert_eq!(
27616 listing
27617 .entries
27618 .iter()
27619 .map(|entry| entry.display_name.as_str())
27620 .collect::<Vec<_>>(),
27621 vec!["directory-a", "directory-b"],
27622 );
27623 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27624 assert!(!listing.incomplete);
27625 std::fs::remove_dir_all(root).unwrap();
27626 }
27627
27628 #[tokio::test]
27629 async fn observer_can_inspect_only_a_registered_workspace() {
27630 let root = temporary_workspace_root("observer");
27631 std::fs::create_dir_all(root.join("src")).unwrap();
27632 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27633 let workspace_id = WorkspaceId::new("primary").unwrap();
27634 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27635 let catalog = active_registry().unwrap();
27636 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27637 let shared = NodeShared::new(
27638 handle,
27639 "fixture-token".to_owned(),
27640 NodeId::new("node-1").unwrap(),
27641 vec![workspace],
27642 vec![agent("claude")],
27643 );
27644
27645 let response = process_request_inner(
27646 &shared,
27647 77,
27648 ClientRole::Observer,
27649 NodeRequest::InspectWorkspace {
27650 workspace_id: workspace_id.clone(),
27651 },
27652 )
27653 .await
27654 .unwrap();
27655 let NodeResponse::WorkspaceInspected { inspection } = response else {
27656 panic!("workspace inspection returned another response");
27657 };
27658 assert_eq!(inspection.workspace_id, workspace_id);
27659 assert!(inspection.entries.iter().any(|entry| {
27660 entry.relative_path.as_utf8() == Some("src/main.rs")
27661 }));
27662
27663 let unknown = process_request_inner(
27664 &shared,
27665 77,
27666 ClientRole::Observer,
27667 NodeRequest::InspectWorkspace {
27668 workspace_id: WorkspaceId::new("unknown").unwrap(),
27669 },
27670 )
27671 .await
27672 .unwrap_err();
27673 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27674 std::fs::remove_dir_all(&root).unwrap();
27675 }
27676
27677 #[test]
27678 fn context_pack_source_accepts_only_running_or_successful_exit() {
27679 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27680 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27681 exit_code: Some(0),
27682 }));
27683 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27684 exit_code: Some(1),
27685 }));
27686 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27687 exit_code: None,
27688 }));
27689 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27690 message: "provider failed".to_owned(),
27691 }));
27692 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27693 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27694 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27695 }
27696
27697 #[test]
27698 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27699 let git = git_snapshot_for_parser();
27700 let first = ContextPackRepositoryObservation {
27701 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27702 git: git.clone(),
27703 files: Vec::new(),
27704 };
27705 let second = ContextPackRepositoryObservation {
27706 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27707 git,
27708 files: Vec::new(),
27709 };
27710
27711 let error = stable_context_pack_repository(first, second).unwrap_err();
27712
27713 assert_eq!(
27714 error.code,
27715 NodeFailureCode::ContextPackMaterializationFailed,
27716 );
27717 }
27718
27719 #[test]
27720 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27721 let git = git_snapshot_for_parser();
27722 let first = ContextPackRepositoryObservation {
27723 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27724 git: git.clone(),
27725 files: vec![ContextPackRepositoryFileSource::utf8(
27726 "README.md",
27727 "first\n".to_owned(),
27728 6,
27729 )],
27730 };
27731 let second = ContextPackRepositoryObservation {
27732 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27733 git,
27734 files: vec![ContextPackRepositoryFileSource::utf8(
27735 "README.md",
27736 "other\n".to_owned(),
27737 6,
27738 )],
27739 };
27740
27741 let error = stable_context_pack_repository(first, second).unwrap_err();
27742
27743 assert_eq!(
27744 error.code,
27745 NodeFailureCode::ContextPackMaterializationFailed,
27746 );
27747 }
27748
27749 #[tokio::test]
27750 async fn native_session_catalog_zero_active_session_fixture() {
27751 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27752 let history_root = temporary_workspace_root("native-catalog-history");
27753 let outside_root = temporary_workspace_root("native-catalog-outside");
27754 std::fs::create_dir_all(&workspace_root).unwrap();
27755 std::fs::create_dir_all(&history_root).unwrap();
27756 std::fs::create_dir_all(&outside_root).unwrap();
27757 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27758 [
27759 serde_json::json!({
27760 "type": "user",
27761 "sessionId": session_id,
27762 "cwd": cwd.to_str().unwrap(),
27763 "message": { "content": "raw user text must not cross the catalog wire" }
27764 }),
27765 serde_json::json!({
27766 "type": "assistant",
27767 "sessionId": session_id,
27768 "cwd": cwd.to_str().unwrap(),
27769 "message": { "model": "claude-sonnet", "content": "raw answer" }
27770 }),
27771 serde_json::json!({
27772 "type": "ai-title",
27773 "sessionId": session_id,
27774 "aiTitle": title
27775 }),
27776 ]
27777 .into_iter()
27778 .map(|record| record.to_string())
27779 .collect::<Vec<_>>()
27780 .join("\n")
27781 };
27782 std::fs::write(
27783 history_root.join("stable-session.jsonl"),
27784 transcript("stable-session", &workspace_root, "Workspace review"),
27785 )
27786 .unwrap();
27787 std::fs::write(
27788 history_root.join("outside-session.jsonl"),
27789 transcript("outside-session", &outside_root, "Outside review"),
27790 )
27791 .unwrap();
27792 let history = NativeHistoryConfig::new(vec![
27793 NativeHistoryRoot::new(
27794 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27795 HistorySourceLayout::SingleNdjson,
27796 history_root.clone(),
27797 )
27798 .unwrap(),
27799 ])
27800 .unwrap();
27801 let catalog = active_registry().unwrap();
27802 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27803 assert!(handle.snapshot().sessions.is_empty());
27804 let workspace_id = WorkspaceId::new("primary").unwrap();
27805 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27806 let mut shared = NodeShared::new(
27807 handle,
27808 "fixture-token".to_owned(),
27809 NodeId::new("node-native-catalog").unwrap(),
27810 vec![workspace],
27811 vec![agent("claude")],
27812 );
27813 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27814 NativeSessionCatalogAuthority::new(history),
27815 )));
27816 let route = NativeSessionCatalogRoute::workspace(
27817 workspace_id.clone(),
27818 agent("claude"),
27819 );
27820 let request = NodeRequest::CatalogNativeSessions {
27821 route: route.clone(),
27822 limit: 2,
27823 };
27824 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27825 .await
27826 .unwrap_err();
27827 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27828 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27829 .await
27830 .unwrap();
27831 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27832 panic!("native catalog returned a different response");
27833 };
27834 let summary = summary.expect("node must emit native session catalog summary metadata");
27835 assert_eq!(entries.len(), 1);
27836 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27837 assert_eq!(entries[0].external_group, None);
27838 assert_eq!(entries[0].record_id, None);
27839 assert_eq!(entries[0].title, None);
27840 assert_eq!(entries[0].model, None);
27841 assert_eq!(entries[0].message_count, 0);
27842 let encoded_entries = serde_json::to_string(&entries).unwrap();
27843 assert!(!encoded_entries.contains("stable-session"));
27844 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27845 let page_request = NodeRequest::PageNativeSessions {
27846 route: route.clone(),
27847 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27848 catalog_revision: summary.catalog_revision,
27849 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27850 after_selection_id: None,
27851 limit: 2,
27852 };
27853 let observer = process_request_inner(
27854 &shared,
27855 7,
27856 ClientRole::Observer,
27857 page_request.clone(),
27858 )
27859 .await
27860 .unwrap_err();
27861 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27862 let page_response = process_request_inner(
27863 &shared,
27864 7,
27865 ClientRole::Operator,
27866 page_request.clone(),
27867 )
27868 .await
27869 .unwrap();
27870 assert!(matches!(
27871 page_response,
27872 NodeResponse::NativeSessionsPaged { page, .. }
27873 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27874 ));
27875 let stale = process_request_inner(
27876 &shared,
27877 7,
27878 ClientRole::Operator,
27879 NodeRequest::PageNativeSessions {
27880 route: route.clone(),
27881 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27882 catalog_revision: summary.catalog_revision.wrapping_add(1),
27883 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27884 after_selection_id: None,
27885 limit: 2,
27886 },
27887 )
27888 .await
27889 .unwrap_err();
27890 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27891 let selection = NativeSessionSelection {
27892 route: route.clone(),
27893 catalog_revision: summary.catalog_revision,
27894 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27895 selection_id: entries[0].selection_id.clone(),
27896 };
27897 let preview_response = process_request_inner(
27898 &shared,
27899 7,
27900 ClientRole::Operator,
27901 NodeRequest::PreviewNativeSession {
27902 selection: selection.clone(),
27903 message_limit: 1,
27904 },
27905 )
27906 .await
27907 .unwrap();
27908 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27909 panic!("native preview returned a different response");
27910 };
27911 assert_eq!(preview.messages.len(), 1);
27912 assert!(preview.message_count_exact);
27913 assert_eq!(preview.messages[0].text, "raw answer");
27914 assert!(preview.truncated);
27915
27916 let index_request = NodeRequest::IndexNativeSession {
27917 selection: selection.clone(),
27918 display_name: "Saved Claude".to_owned(),
27919 };
27920 let observer = process_request_inner(
27921 &shared,
27922 7,
27923 ClientRole::Observer,
27924 index_request.clone(),
27925 )
27926 .await
27927 .unwrap_err();
27928 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27929 let without_controller = process_request_inner(
27930 &shared,
27931 7,
27932 ClientRole::Operator,
27933 index_request.clone(),
27934 )
27935 .await
27936 .unwrap_err();
27937 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27938 let acquired = process_request_inner(
27939 &shared,
27940 7,
27941 ClientRole::Operator,
27942 NodeRequest::AcquireController { lease_ms: 5_000 },
27943 )
27944 .await
27945 .unwrap();
27946 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27947 let indexed = process_request_inner(
27948 &shared,
27949 7,
27950 ClientRole::Operator,
27951 index_request,
27952 )
27953 .await
27954 .unwrap();
27955 assert!(indexed.native_session_index_contract_is_valid());
27956 let NodeResponse::NativeSessionIndexed {
27957 selection: echoed_selection,
27958 record,
27959 } = indexed else {
27960 panic!("native session index returned a different response");
27961 };
27962 assert_eq!(echoed_selection, selection);
27963 let recataloged = process_request_inner(
27964 &shared,
27965 7,
27966 ClientRole::Operator,
27967 NodeRequest::CatalogNativeSessions {
27968 route,
27969 limit: 2,
27970 },
27971 )
27972 .await
27973 .unwrap();
27974 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27975 panic!("native catalog returned a different response after indexing");
27976 };
27977 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27978
27979 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27980 let external_catalog = process_request_inner(
27981 &shared,
27982 7,
27983 ClientRole::Operator,
27984 NodeRequest::CatalogNativeSessions {
27985 route: unregistered_route.clone(),
27986 limit: 2,
27987 },
27988 )
27989 .await
27990 .unwrap();
27991 let external_json = serde_json::to_string(&external_catalog).unwrap();
27992 assert!(!external_json.contains("outside-session"));
27993 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27994 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27995 assert!(!external_json.contains("\"session_id\""));
27996 assert!(!external_json.contains("\"cwd\""));
27997 assert!(!external_json.contains("\"path\""));
27998 let NodeResponse::NativeSessionsCataloged {
27999 route: echoed_route,
28000 entries: external_entries,
28001 summary: Some(external_summary),
28002 } = external_catalog
28003 else {
28004 panic!("unregistered native catalog returned a different response");
28005 };
28006 assert_eq!(echoed_route, unregistered_route);
28007 assert_eq!(external_entries.len(), 1);
28008 assert_eq!(external_entries[0].record_id, None);
28009 let external_group = external_entries[0]
28010 .external_group
28011 .as_ref()
28012 .expect("unregistered session must have an external group");
28013 assert_eq!(
28014 external_group.kind,
28015 gate4agent_types::NativeSessionExternalGroupKind::Project,
28016 );
28017 assert!(external_group.group_id.starts_with("external-"));
28018 assert_eq!(
28019 external_group.display_name,
28020 outside_root.file_name().unwrap().to_string_lossy(),
28021 );
28022 let external_selection = NativeSessionSelection {
28023 route: unregistered_route,
28024 catalog_revision: external_summary.catalog_revision,
28025 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28026 selection_id: external_entries[0].selection_id.clone(),
28027 };
28028 let external_preview = process_request_inner(
28029 &shared,
28030 7,
28031 ClientRole::Operator,
28032 NodeRequest::PreviewNativeSession {
28033 selection: external_selection.clone(),
28034 message_limit: 1,
28035 },
28036 )
28037 .await
28038 .unwrap();
28039 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28040 assert!(!external_preview_json.contains("outside-session"));
28041 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28042 assert!(!external_preview_json.contains("\"session_id\""));
28043 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28044 panic!("unregistered native preview returned a different response");
28045 };
28046 assert_eq!(preview.messages.len(), 1);
28047 let external_index = process_request_inner(
28048 &shared,
28049 7,
28050 ClientRole::Operator,
28051 NodeRequest::IndexNativeSession {
28052 selection: external_selection,
28053 display_name: "External Claude".to_owned(),
28054 },
28055 )
28056 .await
28057 .unwrap_err();
28058 assert_eq!(
28059 external_index.code,
28060 NodeFailureCode::WorkspaceRegistrationRequired,
28061 );
28062 let record_response = process_request_inner(
28063 &shared,
28064 7,
28065 ClientRole::Operator,
28066 NodeRequest::PreviewSessionRecord {
28067 record_id: record.record_id.clone(),
28068 message_limit: 2,
28069 },
28070 )
28071 .await
28072 .unwrap();
28073 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28074 panic!("record preview returned a different response");
28075 };
28076 assert_eq!(record_id, record.record_id);
28077 assert_eq!(preview.messages.len(), 2);
28078 let encoded = serde_json::to_string(&preview).unwrap();
28079 assert!(!encoded.contains("stable-session"));
28080 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28081 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28082 panic!("record preview history summary returned a different response");
28083 };
28084 let summaries = events
28085 .iter()
28086 .filter_map(|envelope| match &envelope.event {
28087 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28088 if record_id == &record.record_id =>
28089 {
28090 Some(*summary)
28091 }
28092 _ => None,
28093 })
28094 .collect::<Vec<_>>();
28095 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28096 let summary_json = serde_json::to_string(&summaries).unwrap();
28097 for private in [
28098 "stable-session",
28099 "raw user text must not cross the catalog wire",
28100 "raw answer",
28101 "Workspace review",
28102 "claude-sonnet",
28103 ] {
28104 assert!(!summary_json.contains(private), "{private}");
28105 }
28106 let identity = record.provider_session.clone().unwrap();
28107 assert_eq!(
28108 shared.revalidate_session_record_preview(&record, &identity),
28109 Ok(())
28110 );
28111 shared.remove_record_memory(&record.record_id);
28112 assert_eq!(
28113 shared
28114 .revalidate_session_record_preview(&record, &identity)
28115 .unwrap_err()
28116 .code,
28117 NodeFailureCode::UnknownSessionRecord
28118 );
28119 let released = process_request_inner(
28120 &shared,
28121 7,
28122 ClientRole::Operator,
28123 NodeRequest::ReleaseController,
28124 )
28125 .await
28126 .unwrap();
28127 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28128 assert!(shared.controller_state().is_none());
28129 std::fs::remove_dir_all(workspace_root).unwrap();
28130 std::fs::remove_dir_all(history_root).unwrap();
28131 std::fs::remove_dir_all(outside_root).unwrap();
28132 }
28133
28134 #[tokio::test]
28135 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28136 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28137 std::fs::create_dir_all(&history_root).unwrap();
28138 let history = NativeHistoryConfig::new(vec![
28139 NativeHistoryRoot::new(
28140 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28141 HistorySourceLayout::SingleNdjson,
28142 history_root.clone(),
28143 )
28144 .unwrap(),
28145 ])
28146 .unwrap();
28147 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28148 let held = catalog.lock().unwrap();
28149 let result = timeout(
28150 Duration::from_secs(1),
28151 run_native_session_catalog_operation(
28152 Arc::clone(&catalog),
28153 "native session catalog",
28154 |_| Ok::<(), std::convert::Infallible>(()),
28155 ),
28156 )
28157 .await
28158 .expect("concurrent native catalog request must not wait for the operation deadline")
28159 .unwrap_err();
28160 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28161 drop(held);
28162 std::fs::remove_dir_all(history_root).unwrap();
28163 }
28164
28165 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28166 let unique = std::time::SystemTime::now()
28167 .duration_since(std::time::UNIX_EPOCH)
28168 .unwrap()
28169 .as_nanos();
28170 std::env::temp_dir().join(format!(
28171 "gate4agent-node-{label}-{}-{unique}",
28172 std::process::id(),
28173 ))
28174 }
28175
28176 fn git_snapshot_for_parser() -> GitSnapshot {
28177 GitSnapshot {
28178 is_repository: true,
28179 branch: Some("main".to_owned()),
28180 status: Vec::new(),
28181 recent_commits: Vec::new(),
28182 worktrees: Vec::new(),
28183 managed_worktree: None,
28184 truncated: false,
28185 diagnostic: None,
28186 }
28187 }
28188
28189 #[tokio::test]
28190 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28191 let root = temporary_workspace_root("managed-git-scope");
28192 std::fs::create_dir_all(&root).unwrap();
28193 run_test_git(&root, &["init"]);
28194 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28195 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28196 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28197 run_test_git(&root, &["add", "seed.txt"]);
28198 run_test_git(&root, &["commit", "-m", "seed"]);
28199 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28200
28201 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28202 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28203 let catalog = active_registry().unwrap();
28204 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28205 let shared = NodeShared::new(
28206 handle,
28207 "fixture-token".to_owned(),
28208 NodeId::new("node-1").unwrap(),
28209 vec![workspace],
28210 vec![agent("claude")],
28211 );
28212 let mut lease = ManagedWorktreeLeaseRecord {
28213 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28214 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28215 workspace_id: workspace_id.clone(),
28216 profile_id: WorktreeProfileId::new("default").unwrap(),
28217 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28218 target_root: root.to_string_lossy().into_owned(),
28219 branch: "gate4agent/a".to_owned(),
28220 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28221 expected_head: None,
28222 retention: ManagedWorktreeRetention::Retain,
28223 state: ManagedWorktreeLeaseState::InUse,
28224 session_holders: vec![ManagedWorktreeSessionHolder {
28225 incarnation_id: shared.incarnation_id,
28226 instance_id: AgentInstanceId(7),
28227 generation: SessionGeneration(1),
28228 }],
28229 record_holders: Vec::new(),
28230 cleanup_failure: None,
28231 created_at_unix_ms: 1,
28232 updated_at_unix_ms: 1,
28233 };
28234 *shared
28235 .managed_worktrees
28236 .lock()
28237 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28238 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28239
28240 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28241 let scope = inspection.git.managed_worktree.unwrap();
28242 assert_eq!(scope.lease_id, lease.lease_id);
28243 assert_eq!(scope.branch, "gate4agent/a");
28244 assert_eq!(scope.active_session_count, 1);
28245
28246 lease.branch = "gate4agent/b".to_owned();
28247 *shared
28248 .managed_worktrees
28249 .lock()
28250 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28251 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28252 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28253 assert!(mismatch.git.managed_worktree.is_none());
28254 std::fs::remove_dir_all(root).unwrap();
28255 }
28256
28257 #[tokio::test]
28258 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28259 let root = temporary_workspace_root("workspace-file-write-controller");
28260 std::fs::create_dir_all(root.join("src")).unwrap();
28261 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28262 let workspace_id = WorkspaceId::new("primary").unwrap();
28263 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28264 let catalog = active_registry().unwrap();
28265 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28266 let shared = NodeShared::new(
28267 handle,
28268 "fixture-token".to_owned(),
28269 NodeId::new("node-1").unwrap(),
28270 vec![workspace],
28271 vec![agent("claude")],
28272 );
28273 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28274 let expected_revision = workspace_file_revision(b"old\n");
28275 let request = NodeRequest::WriteWorkspaceFile {
28276 workspace_id: workspace_id.clone(),
28277 path: path.clone(),
28278 expected_revision: expected_revision.clone(),
28279 text: "new\n".to_owned(),
28280 };
28281 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28282 .await
28283 .unwrap_err();
28284 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28285 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28286 .await
28287 .unwrap_err();
28288 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28289 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28290 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28291 let NodeResponse::WorkspaceFileWritten { file } = response else {
28292 panic!("workspace file write returned another response");
28293 };
28294 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28295 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28296 let stale = process_request_inner(
28297 &shared,
28298 77,
28299 ClientRole::Operator,
28300 NodeRequest::WriteWorkspaceFile {
28301 workspace_id,
28302 path,
28303 expected_revision,
28304 text: "lost update\n".to_owned(),
28305 },
28306 )
28307 .await
28308 .unwrap_err();
28309 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28310 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28311 std::fs::remove_dir_all(root).unwrap();
28312 }
28313
28314 #[tokio::test]
28315 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28316 let root = temporary_workspace_root("workspace-entry-create-controller");
28317 std::fs::create_dir_all(root.join("src")).unwrap();
28318 let workspace_id = WorkspaceId::new("primary").unwrap();
28319 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28320 let catalog = active_registry().unwrap();
28321 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28322 let shared = NodeShared::new(
28323 handle,
28324 "fixture-token".to_owned(),
28325 NodeId::new("node-1").unwrap(),
28326 vec![workspace],
28327 vec![agent("claude")],
28328 );
28329 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28330 let request = NodeRequest::CreateWorkspaceFile {
28331 workspace_id: workspace_id.clone(),
28332 path: path.clone(),
28333 };
28334 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28335 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28336 }));
28337 assert!(!request_uses_unnegotiated_capability(
28338 &request,
28339 &baseline_capabilities().unwrap(),
28340 ));
28341 assert!(request_uses_unnegotiated_capability(&request, &[]));
28342 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28343 .await
28344 .unwrap_err();
28345 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28346 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28347 .await
28348 .unwrap_err();
28349 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28350 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28351
28352 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28353 .await
28354 .unwrap();
28355 let NodeResponse::WorkspaceFileCreated { file } = response else {
28356 panic!("workspace file create returned another response");
28357 };
28358 assert_eq!(file.workspace_id, workspace_id);
28359 assert_eq!(file.path, path);
28360 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28361 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28362
28363 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28364 .await
28365 .unwrap_err();
28366 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28367
28368 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28369 let response = process_request_inner(
28370 &shared,
28371 77,
28372 ClientRole::Operator,
28373 NodeRequest::CreateWorkspaceDirectory {
28374 workspace_id: workspace_id.clone(),
28375 path: directory_path.clone(),
28376 },
28377 )
28378 .await
28379 .unwrap();
28380 assert_eq!(
28381 response,
28382 NodeResponse::WorkspaceDirectoryCreated {
28383 workspace_id: workspace_id.clone(),
28384 entry: WorkspaceEntry {
28385 relative_path: directory_path,
28386 kind: WorkspaceEntryKind::Directory,
28387 },
28388 },
28389 );
28390 assert!(root.join("src/nested").is_dir());
28391
28392 let missing_parent = process_request_inner(
28393 &shared,
28394 77,
28395 ClientRole::Operator,
28396 NodeRequest::CreateWorkspaceFile {
28397 workspace_id: workspace_id.clone(),
28398 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28399 },
28400 )
28401 .await
28402 .unwrap_err();
28403 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28404 let non_directory_parent = process_request_inner(
28405 &shared,
28406 77,
28407 ClientRole::Operator,
28408 NodeRequest::CreateWorkspaceFile {
28409 workspace_id,
28410 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28411 },
28412 )
28413 .await
28414 .unwrap_err();
28415 assert_eq!(
28416 non_directory_parent.code,
28417 NodeFailureCode::RepositoryParentNotDirectory,
28418 );
28419 std::fs::remove_dir_all(root).unwrap();
28420 }
28421
28422 #[tokio::test]
28423 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28424 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28425 std::fs::create_dir_all(root.join("src")).unwrap();
28426 #[cfg(windows)]
28427 let root = std::fs::canonicalize(root).unwrap();
28428 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28429 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28430 let barrier = Arc::new(std::sync::Barrier::new(2));
28431 let done = Arc::new(AtomicBool::new(false));
28432 let task_commit_state = Arc::clone(&commit_state);
28433 let task_barrier = Arc::clone(&barrier);
28434 let task_done = Arc::clone(&done);
28435 let task_root = root.clone();
28436 let task = tokio::task::spawn_blocking(move || {
28437 task_barrier.wait();
28438 let result = create_workspace_file_on_disk(
28439 &task_root,
28440 &path,
28441 &task_commit_state,
28442 );
28443 task_done.store(true, Ordering::Release);
28444 result
28445 });
28446
28447 let error = settle_workspace_entry_create(
28448 task,
28449 Arc::clone(&commit_state),
28450 Duration::from_millis(10),
28451 "fixture deadline",
28452 "fixture task failed",
28453 )
28454 .await
28455 .unwrap_err();
28456 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28457 assert_eq!(
28458 commit_state.load(Ordering::Acquire),
28459 WORKSPACE_ENTRY_CREATE_CANCELED,
28460 );
28461
28462 barrier.wait();
28463 timeout(Duration::from_secs(1), async {
28464 while !done.load(Ordering::Acquire) {
28465 tokio::task::yield_now().await;
28466 }
28467 })
28468 .await
28469 .expect("canceled blocking create did not settle");
28470 assert!(!root.join("src/late.rs").exists());
28471 std::fs::remove_dir_all(root).unwrap();
28472 }
28473}
28474
28475#[cfg(test)]
28476mod provisional_spawn_tests {
28477 use super::*;
28478 use std::sync::Arc;
28479
28480 fn shared() -> NodeShared {
28481 let catalog = active_registry().unwrap();
28482 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28483 let workspace = WorkspaceConfig::new(
28484 WorkspaceId::new("primary").unwrap(),
28485 std::env::current_dir().unwrap(),
28486 )
28487 .unwrap()
28488 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28489 NodeShared::new(
28490 handle,
28491 "fixture-token".to_owned(),
28492 NodeId::new("node-provisional-test").unwrap(),
28493 vec![workspace],
28494 vec![AgentId::new("claude").unwrap()],
28495 )
28496 }
28497
28498 #[test]
28501 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28502 let shared = shared();
28503 let ws = WorkspaceId::new("primary").unwrap();
28504 let mut ids = Vec::with_capacity(50);
28505 for i in 1u64..=50 {
28506 let id = AgentInstanceId(i);
28507 ids.push(id);
28508 let address = SessionAddress {
28509 workspace_id: ws.clone(),
28510 session: SessionKey {
28511 instance_id: id,
28512 generation: SessionGeneration(1),
28513 },
28514 };
28515 shared.mark_spawn_provisional(address);
28516 }
28517 assert_eq!(
28518 shared
28519 .provisional_spawns
28520 .lock()
28521 .unwrap_or_else(|p| p.into_inner())
28522 .len(),
28523 50
28524 );
28525 for id in ids.iter().step_by(2) {
28526 shared.sessionize_spawn(*id);
28527 }
28528 assert_eq!(
28529 shared
28530 .provisional_spawns
28531 .lock()
28532 .unwrap_or_else(|p| p.into_inner())
28533 .len(),
28534 25
28535 );
28536 assert!(shared.take_due_provisional_spawns().is_empty());
28537 {
28538 let mut map = shared
28539 .provisional_spawns
28540 .lock()
28541 .unwrap_or_else(|p| p.into_inner());
28542 let past = Instant::now() - Duration::from_secs(91);
28543 for (_, (_, at)) in map.iter_mut() {
28544 *at = past;
28545 }
28546 }
28547 let due = shared.take_due_provisional_spawns();
28548 assert_eq!(due.len(), 25);
28549 let due_ids: std::collections::BTreeSet<_> =
28550 due.iter().map(|a| a.session.instance_id.0).collect();
28551 let expected: std::collections::BTreeSet<_> =
28552 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28553 assert_eq!(due_ids, expected);
28554 assert!(shared
28555 .provisional_spawns
28556 .lock()
28557 .unwrap_or_else(|p| p.into_inner())
28558 .is_empty());
28559 }
28560
28561 #[test]
28564 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28565 let shared = Arc::new(shared());
28566 let ws = WorkspaceId::new("primary").unwrap();
28567 let mut handles = Vec::new();
28568 for i in 1u64..=20 {
28569 let shared = Arc::clone(&shared);
28570 let ws = ws.clone();
28571 handles.push(std::thread::spawn(move || {
28572 let id = AgentInstanceId(i);
28573 let address = SessionAddress {
28574 workspace_id: ws,
28575 session: SessionKey {
28576 instance_id: id,
28577 generation: SessionGeneration(1),
28578 },
28579 };
28580 shared.mark_spawn_provisional(address);
28581 id
28582 }));
28583 }
28584 let mut ids = Vec::new();
28585 for h in handles {
28586 ids.push(h.join().unwrap());
28587 }
28588 ids.sort_by_key(|id| id.0);
28589 assert_eq!(ids.len(), 20);
28590 ids.dedup();
28591 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28592 assert_eq!(
28593 shared
28594 .provisional_spawns
28595 .lock()
28596 .unwrap_or_else(|p| p.into_inner())
28597 .len(),
28598 20
28599 );
28600 shared.sessionize_spawn(AgentInstanceId(7));
28601 let left: Vec<_> = shared
28602 .provisional_spawns
28603 .lock()
28604 .unwrap_or_else(|p| p.into_inner())
28605 .keys()
28606 .map(|id| id.0)
28607 .collect();
28608 assert!(!left.contains(&7));
28609 assert_eq!(left.len(), 19);
28610 }
28611}
28612
28613
28614#[cfg(unix)]
28615extern "C" {
28616 #[link_name = "geteuid"]
28617 fn libc_geteuid() -> u32;
28618}
28619
28620#[cfg(test)]
28621mod call_home_link_tests {
28622 use super::*;
28623
28624 #[test]
28625 fn call_home_backoff_is_capped_and_monotonic() {
28626 let mut last = Duration::ZERO;
28627 for attempt in 1..20 {
28628 let next = call_home_backoff(attempt);
28629 assert!(next >= last);
28630 assert!(next <= Duration::from_secs(8));
28631 last = next;
28632 }
28633 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28634 }
28635
28636 #[tokio::test]
28637 async fn call_home_socket_gets_keepalive() {
28638 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28639 let addr = listener.local_addr().unwrap();
28640 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28641 let client = client.unwrap();
28642 enable_call_home_keepalive(&client).unwrap();
28643 let sock = socket2::SockRef::from(&client);
28644 assert!(sock.keepalive().unwrap());
28645 #[cfg(any(target_os = "linux", target_os = "macos"))]
28646 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28647 }
28648}
28649
28650fn provider_session_exists(provider: &str, id: &str) -> bool {
28652 if provider != "claude" { return false; }
28653 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28654 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28655 let name = format!("{id}.jsonl");
28656 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())))
28657}
28658
28659fn overlay_with_extra_args(
28661 provider: &AgentId,
28662 mode: SessionMode,
28663 overlay: Option<PreparedNativeLaunchOverlay>,
28664 args: Vec<OsString>,
28665) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28666 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28667 Ok(match (overlay, args) {
28668 (prepared, args) if args.is_empty() => prepared,
28669 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28670 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28671 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28672 provider.clone(),
28673 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28674 Vec::new(),
28675 args,
28676 ).map_err(|_| bad())?)),
28677 })
28678}
28679
28680fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28682 use crate::provider_error::Action;
28683 match act {
28684 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"),
28685 Action::Limit { found } => {
28686 tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28687 if via == "acp" {
28688 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28689 }
28690 }
28691 Action::Escalate { found, attempts } => {
28692 tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28693 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28694 }
28695 }
28696}
28697
28698fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28701 let id = address.session.instance_id.0;
28702 let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28703 match e {
28704 ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28705 ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28706 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"),
28707 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"),
28708 ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28709 ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28710 ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28711 _ => {}
28712 }
28713}