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) {
3537 if let Some(file) = self.session_resume_journal() {
3538 crate::pty_resume::remove(&file, address.session.instance_id.0);
3539 }
3540 }
3541 if let Some(accounts) = self.m4a_accounts.as_ref() {
3542 let instance = address.session.instance_id;
3543 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3544 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3545 accounts.remove_session_file(nick);
3546 }
3547 }
3548 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549 registry.revoke_session(address);
3550 }
3551 if let Some(control) = self.native_launch_profile_control.as_ref() {
3552 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3553 }
3554 }
3555
3556 fn on_shutdown(&self) {
3557 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3558 .into_values()
3559 .collect();
3560 for socket in leaders {
3561 crate::grok_leader::retire(&socket);
3562 }
3563 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3564 let instances = registry.shutdown();
3565 if let Some(control) = self.native_launch_profile_control.as_ref() {
3566 for instance_id in instances {
3567 control.clear_native_mcp_server_launch_overlay(instance_id);
3568 }
3569 }
3570 }
3571 }
3572
3573 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3574 if let Some(control) = self.native_launch_profile_control.as_ref() {
3575 control.clear_native_mcp_server_launch_overlay(instance_id);
3576 }
3577 }
3578
3579 fn cleanup_session_materialization(
3580 &self,
3581 address: &SessionAddress,
3582 binding: &SessionBinding,
3583 ) -> Result<(), NodeFailure> {
3584 self.cleanup_session_owned_materialization(address, binding)
3585 }
3586
3587 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3588 #[cfg(feature = "dig2-station-probe")]
3591 {
3592 crate::dig2_station_lease::release_lease_for_holder(
3593 &self.browser_station_leases,
3594 address.session.instance_id,
3595 );
3596 }
3597 if let Some(control) = self.native_launch_profile_control.as_ref() {
3598 control.clear_native_mcp_server_launch_overlay(
3599 address.session.instance_id,
3600 );
3601 }
3602 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3603 registry.revoke_session(address);
3604 }
3605 if removed
3606 .is_some_and(|binding| {
3607 binding.environment_profile.is_some()
3608 || binding.bundle.is_some()
3609 || binding.context.is_some()
3610 })
3611 {
3612 if let Some(control) = self.native_launch_profile_control.as_ref() {
3613 control.clear_native_launch_profile_selection(
3614 address.session.instance_id,
3615 );
3616 control.clear_native_instance_launch_overlay(
3617 address.session.instance_id,
3618 );
3619 }
3620 }
3621 }
3622}
3623
3624struct SpawnPrepareRequest<'r> {
3625 instance_id: AgentInstanceId,
3626 address: &'r SessionAddress,
3627 provider: &'r AgentId,
3628 mode: SessionMode,
3629 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3630 bundle: Option<&'r ResolvedBundleReceipt>,
3631 context: Option<&'r ResolvedContextPackReceipt>,
3632 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3633 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3634 approval_level: ApprovalLevel,
3635 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3636 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3637}
3638
3639struct PreparedSpawn<'a> {
3643 instance_overlay: Option<NativeInstanceOverlayGuard>,
3644 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3645 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3646 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3647 #[cfg(feature = "dig2-station-probe")]
3648 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3649 materialization_id: Option<MaterializationId>,
3650}
3651
3652impl PreparedSpawn<'_> {
3653 fn retain_session_guards(&mut self) {
3655 if let Some(selection) = self.environment_selection.take() {
3656 selection.retain();
3657 }
3658 if let Some(overlay) = self.instance_overlay.take() {
3659 overlay.retain();
3660 }
3661 if let Some(guard) = self.materialization_guard.take() {
3662 guard.retain();
3663 }
3664 }
3665
3666 fn retain_launch_guards(&mut self) {
3668 if let Some(overlay) = self.harness_mcp_overlay.take() {
3669 overlay.retain();
3670 }
3671 #[cfg(feature = "dig2-station-probe")]
3672 if let Some(guard) = self.browser_station_lease_guard.take() {
3673 guard.retain();
3674 }
3675 }
3676}
3677
3678trait SpawnPreparer {
3682 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3683 fn select_environment(
3685 &self,
3686 instance_id: AgentInstanceId,
3687 provider: &AgentId,
3688 mode: SessionMode,
3689 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3690 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3691 fn install_launch_overlay(
3693 &self,
3694 instance_id: AgentInstanceId,
3695 overlay: PreparedNativeLaunchOverlay,
3696 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3697}
3698
3699impl NodeShared {
3700 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3703 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3704 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3705 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3706 if let Some(file) = self.session_resume_journal() {
3707 crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3708 }
3709 key
3710 }
3711
3712 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3713 let accounts = self.m4a_accounts.as_ref()?;
3714 let nick = self.m4a_nick_of(instance_id)?;
3715 match accounts.session_workdir(root, provider.as_str(), &nick) {
3716 Ok(dir) => dir,
3717 Err(error) => {
3718 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3719 None
3720 }
3721 }
3722 }
3723
3724 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3725 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3726 }
3727
3728 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3731 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3732 for record in records.records.values_mut() {
3733 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3734 record.display_name = nick.to_owned();
3735 }
3736 }
3737 }
3738
3739 fn m4a_session_overlay(
3742 &self,
3743 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3744 m4a_provider: &str,
3745 provider: &AgentId,
3746 instance_id: AgentInstanceId,
3747 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3748 session_key: &str,
3749 mode: SessionMode,
3750 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3751 let session_key = session_key.to_owned();
3752 if mode == SessionMode::Acp {
3753 return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3754 }
3755 Ok( match accounts.provision(m4a_provider, &session_key) {
3756 Ok(account) => {
3757 crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3758 let mut identity = account.env_mutations();
3759 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3760 let merged = match environment_overlay {
3761 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3762 .map(PreparedNativeLaunchOverlay::Instance),
3763 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3764 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3765 }
3766 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3767 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3768 }
3769 }
3770 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3771 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3772 let mut merged = merged;
3773 match accounts.write_session_file(&account) {
3774 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3775 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3776 Ok(args) if !args.is_empty() => {
3777 merged = match merged {
3778 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3779 .append_extra_args(args)
3780 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3781 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3782 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3783 .into_instance_with_extra_args(args)
3784 .map(PreparedNativeLaunchOverlay::Instance)
3785 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3786 };
3787 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3788 }
3789 Ok(_) => {}
3790 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3791 },
3792 }
3793 self.m4a_live
3794 .lock()
3795 .unwrap_or_else(|p| p.into_inner())
3796 .insert(account.nick.clone(), (instance_id, provider.clone()));
3797 self.name_record_after_nick(instance_id, &account.nick);
3798 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3799 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3801 Some(merged)
3802 }
3803 Err(error) => {
3804 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3805 environment_overlay
3806 }
3807 })
3808 }
3809}
3810
3811impl NodeShared {
3812 fn m4a_acp_mcp_overlay(
3815 &self,
3816 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3817 m4a_provider: &str,
3818 provider: &AgentId,
3819 instance_id: AgentInstanceId,
3820 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3821 session_key: &str,
3822 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3823 let Some(control) = self.native_launch_profile_control.as_ref() else {
3824 tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3825 return Ok(environment_overlay);
3826 };
3827 let account = match accounts.provision(m4a_provider, session_key) {
3828 Ok(a) => a,
3829 Err(error) => {
3830 tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3831 return Ok(environment_overlay);
3832 }
3833 };
3834 let session_file = match accounts.write_session_file(&account) {
3835 Ok(p) => p,
3836 Err(error) => {
3837 tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3838 return Ok(environment_overlay);
3839 }
3840 };
3841 let env_entries: Vec<(OsString, OsString)> = account
3842 .env_mutations()
3843 .into_iter()
3844 .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3845 .filter_map(|m| m.value.map(|v| (m.key, v)))
3846 .collect();
3847 let args: Vec<OsString> = vec![
3848 "--session-file".into(),
3849 session_file.into_os_string(),
3850 "mcp".into(),
3851 "--provider".into(),
3852 provider.as_str().into(),
3853 ];
3854 let overlay = NativeMcpServerLaunchOverlay::new(
3855 provider.clone(),
3856 crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3857 accounts.bin().as_os_str().to_os_string(),
3858 args,
3859 env_entries,
3860 Vec::new(),
3861 );
3862 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())) {
3863 Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3864 Err(error) => {
3865 tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3866 return Ok(environment_overlay);
3867 }
3868 }
3869 self.m4a_live
3870 .lock()
3871 .unwrap_or_else(|p| p.into_inner())
3872 .insert(account.nick.clone(), (instance_id, provider.clone()));
3873 self.name_record_after_nick(instance_id, &account.nick);
3874 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3875 Ok(environment_overlay)
3876 }
3877}
3878
3879impl SpawnPreparer for NodeShared {
3880 fn select_environment(
3881 &self,
3882 instance_id: AgentInstanceId,
3883 provider: &AgentId,
3884 mode: SessionMode,
3885 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3886 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3887 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3888 }
3889
3890 fn install_launch_overlay(
3891 &self,
3892 instance_id: AgentInstanceId,
3893 overlay: PreparedNativeLaunchOverlay,
3894 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3895 self.install_prepared_launch_overlay(instance_id, overlay)
3896 }
3897
3898 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3899 let SpawnPrepareRequest {
3900 instance_id,
3901 address,
3902 provider,
3903 mode,
3904 environment_profile,
3905 bundle,
3906 context,
3907 managed_authority,
3908 harness_mcp,
3909 approval_level,
3910 network_allowlist,
3911 browser_profile_id,
3912 } = request;
3913 #[cfg(feature = "dig2-station-probe")]
3918 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3919 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3920 match acquire_lease(
3921 &self.browser_station_leases,
3922 profile_id,
3923 instance_id,
3924 unix_time_ms(),
3925 ) {
3926 Ok(guard) => Some(guard),
3927 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3928 return Err(failure(
3929 NodeFailureCode::BrowserStationProfileBusy,
3930 "dig2browser station profile is already leased by another session",
3931 ));
3932 }
3933 Err(BrowserStationLeaseError::CapacityExceeded) => {
3934 return Err(failure(
3935 NodeFailureCode::BrowserStationProfileBusy,
3936 "dig2browser station profile lease capacity exhausted",
3937 ));
3938 }
3939 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3940 return Err(failure(
3941 NodeFailureCode::BrowserStationProfileBusy,
3942 "session already holds a dig2browser station profile lease",
3943 ));
3944 }
3945 }
3946 } else {
3947 None
3948 };
3949 #[cfg(not(feature = "dig2-station-probe"))]
3950 let _ = browser_profile_id;
3951 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3952 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3961 {
3962 return Err(failure(
3963 NodeFailureCode::BindingMismatch,
3964 "harness MCP reservation does not match the exact PTY/ACP provider",
3965 ));
3966 }
3967 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3968 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3969 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3970 })?;
3971 let launch = prepared.launch();
3976 let mut env_entries = vec![
3977 (
3978 OsString::from(launch.endpoint_env.as_str()),
3979 prepared.endpoint().as_os_str().to_os_string(),
3980 ),
3981 (
3982 OsString::from(launch.token_env.as_str()),
3983 prepared.token().expose().into(),
3984 ),
3985 (
3986 OsString::from(launch.program_env.as_str()),
3987 prepared.helper_program().as_os_str().to_os_string(),
3988 ),
3989 ];
3990 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3991 env_entries.push(trace_entry);
3992 }
3993 if let Some(door) = crate::door::door_listen_addr() {
3994 env_entries.push((
3995 OsString::from("HATCHERY_NODE_DOOR"),
3996 OsString::from(door.to_string()),
3997 ));
3998 let session_key = format!(
4002 "pty-{}-{}-{}",
4003 self.incarnation_id,
4004 address.workspace_id.as_str(),
4005 address.session.instance_id.0
4006 );
4007 env_entries.push((
4008 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
4009 OsString::from(session_key),
4010 ));
4011 }
4012 let overlay = NativeMcpServerLaunchOverlay::new(
4013 provider.clone(),
4014 launch.server_name.as_str(),
4015 prepared.helper_program().as_os_str().to_os_string(),
4016 launch.args.iter().map(OsString::from).collect(),
4017 env_entries,
4018 launch.scrub_env.iter().map(OsString::from).collect(),
4019 ).map_err(|_| failure(
4020 NodeFailureCode::HarnessMcpUnavailable,
4021 "harness MCP launch overlay is unavailable",
4022 ))?;
4023 control
4024 .install_native_mcp_server_launch_overlay(instance_id, overlay)
4025 .map_err(|_| failure(
4026 NodeFailureCode::HarnessMcpUnavailable,
4027 "harness MCP launch overlay could not be installed",
4028 ))?;
4029 tracing::info!(
4035 node_id = %self.node_id,
4036 instance_id = ?instance_id,
4037 provider = %provider,
4038 mode = ?mode,
4039 "harness MCP overlay installed for spawn",
4040 );
4041 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4042 } else {
4043 None
4044 };
4045 let prepared_materialization = self.prepare_session_materialization(
4046 &address,
4047 &provider,
4048 mode,
4049 environment_profile,
4050 bundle,
4051 context,
4052 managed_authority.cloned(),
4053 )?;
4054 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4055 Some((overlay, guard)) => (Some(overlay), Some(guard)),
4056 None => (None, None),
4057 };
4058 let materialization_id = materialization_guard
4059 .as_ref()
4060 .and_then(SessionMaterializationGuard::id)
4061 .cloned();
4062 let mut environment_selection = SpawnPreparer::select_environment(self,
4063 instance_id,
4064 &provider,
4065 mode,
4066 environment_profile,
4067 )?;
4068 let provider_home = materialization_guard
4071 .as_ref()
4072 .and_then(SessionMaterializationGuard::provider_home);
4073 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4074 &provider,
4075 mode,
4076 approval_level,
4077 network_allowlist,
4078 provider_home,
4079 )?;
4080 let mut network_overlay_args = network_overlay_args;
4081 {
4083 let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4084 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4085 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4086 homes.extend(std::env::split_paths(&extra));
4087 }
4088 if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4089 crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4090 network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4091 }
4092 if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4093 crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4094 }
4095 if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4097 if let Some(file) = self.session_resume_journal() {
4098 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4099 instance_id: instance_id.0,
4100 provider: provider.as_str().to_owned(),
4101 workspace_id: address.workspace_id.as_str().to_owned(),
4102 provider_session_id: String::new(),
4103 session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4104 .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4105 .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4106 cwd: String::new(),
4107 acp: mode == SessionMode::Acp,
4108 options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4109 cols: 0,
4110 rows: 0,
4111 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4112 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4113 });
4114 if let Some(dir) = file.parent() {
4115 crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4116 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4117 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4118 provider: Some(provider.as_str().to_owned()),
4119 ..crate::session_meta::Meta::default()
4120 });
4121 crate::session_meta::revive(dir, instance_id.0);
4122 }
4123 tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4124 }
4125 }
4126 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4127 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4128 }
4129 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4130 (prepared, args) if args.is_empty() => prepared,
4131 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4132 overlay.append_extra_args(args).map_err(|_| {
4133 failure(
4134 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4135 "network allowlist overlay could not merge with instance launch args",
4136 )
4137 })?;
4138 Some(PreparedNativeLaunchOverlay::Instance(overlay))
4139 }
4140 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4141 Some(PreparedNativeLaunchOverlay::Instance(
4142 overlay
4143 .into_instance_with_extra_args(args)
4144 .map_err(|_| {
4145 failure(
4146 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4147 "network allowlist overlay could not merge with environment overlay",
4148 )
4149 })?,
4150 ))
4151 }
4152 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4153 NativeInstanceLaunchOverlay::new(
4154 provider.clone(),
4155 match mode {
4156 SessionMode::Pty => TransportKind::Pty,
4157 SessionMode::Acp => TransportKind::Acp,
4158 SessionMode::Inline => TransportKind::Pipe,
4159 },
4160 Vec::new(),
4161 args,
4162 )
4163 .map_err(|_| {
4164 failure(
4165 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4166 "network allowlist overlay is invalid for this transport",
4167 )
4168 })?,
4169 )),
4170 };
4171 #[cfg(unix)]
4174 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4176 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4177 });
4178 #[cfg(unix)]
4179 let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4182 #[cfg(unix)]
4183 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4184 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4185 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4186 let uid = unsafe { libc_geteuid() };
4187 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4188 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4189 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4190 Some(vec![
4191 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4192 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4193 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4194 ])
4195 }
4196 _ => None,
4197 };
4198 #[cfg(not(unix))]
4199 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4200 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4201 (SessionMode::Pty, _, _, Some(identity)) => {
4202 let merged = match environment_overlay {
4203 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4204 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4205 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4206 }
4207 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4208 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4209 }
4210 }
4211 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4212 Some(merged)
4213 }
4214 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4215 let session_key = self.m4a_session_key(instance_id);
4216 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4217 }
4218 (SessionMode::Pty, Some(_), None, _) => {
4219 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4220 environment_overlay
4221 }
4222 _ => environment_overlay,
4223 };
4224 let mut instance_overlay = environment_overlay
4225 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4226 .transpose()?
4227 .flatten();
4228 Ok(PreparedSpawn {
4229 instance_overlay,
4230 environment_selection,
4231 materialization_guard,
4232 harness_mcp_overlay,
4233 #[cfg(feature = "dig2-station-probe")]
4234 browser_station_lease_guard,
4235 materialization_id,
4236 })
4237 }
4238}
4239
4240struct SessionRecords {
4241 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4242}
4243
4244struct SpawnIdempotencyCache {
4245 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4246}
4247
4248struct SpawnIdempotencyEntry {
4249 value: SpawnIdempotencyValue,
4250 expires_at: Instant,
4251}
4252
4253enum SpawnIdempotencyValue {
4254 Standard {
4255 spec: SpawnSpec,
4256 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4257 },
4258 Managed {
4259 request: ManagedWorktreeSpawnRequest,
4260 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4261 },
4262 HarnessMcp,
4263}
4264
4265enum DurableManagedSpawnReplayDecision {
4266 Reserved,
4267 PendingLinked,
4268 Committed(ManagedWorktreeSpawnReceipt),
4269}
4270
4271impl SpawnIdempotencyValue {
4272 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4273 match self {
4274 Self::Standard { result, .. } => result
4275 .as_ref()
4276 .ok()
4277 .and_then(|receipt| receipt.context.as_ref()),
4278 Self::Managed { result, .. } => result
4279 .as_ref()
4280 .ok()
4281 .and_then(|receipt| receipt.spawn.context.as_ref()),
4282 Self::HarnessMcp => None,
4283 }
4284 .is_some_and(|receipt| &receipt.id == context_id)
4285 }
4286}
4287
4288#[derive(Clone, Copy)]
4289struct ControllerLease {
4290 connection_id: u64,
4291 expires_at: Instant,
4292}
4293
4294struct NodeEventHistory {
4295 last_sequence: u64,
4296 replay_floor_sequence: u64,
4297 events: VecDeque<NodeEventEnvelope>,
4298 record_providers: BTreeMap<SessionRecordId, AgentId>,
4299 removed_record_providers: BTreeMap<u64, AgentId>,
4300}
4301
4302impl NodeEventHistory {
4303 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4304 Self {
4305 last_sequence: 0,
4306 replay_floor_sequence: 1,
4307 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4308 record_providers,
4309 removed_record_providers: BTreeMap::new(),
4310 }
4311 }
4312}
4313
4314impl NodeShared {
4315 fn session_resume_journal(&self) -> Option<PathBuf> {
4319 self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4320 }
4321
4322 async fn restore_journalled_sessions(&self) {
4325 let Some(file) = self.session_resume_journal() else { return };
4326 if !crate::pty_resume::enabled() { return; }
4327 for e in crate::pty_resume::load(&file) {
4328 crate::pty_resume::remove(&file, e.instance_id);
4329 if !e.session_key.is_empty() {
4331 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4332 }
4333 let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4334 if e.acp {
4335 let record_id = {
4336 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4337 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())
4338 };
4339 let cur = self.next_instance_id.load(Ordering::Acquire);
4340 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4341 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4342 let resumed = match record_id {
4343 Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4344 None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4345 };
4346 let r = match resumed {
4348 Ok(a) => Ok(a),
4349 Err(err) => {
4350 tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4351 self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4352 SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4353 }
4354 };
4355 match r {
4356 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4357 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4358 }
4359 continue;
4360 }
4361 if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4362 let cur = self.next_instance_id.load(Ordering::Acquire);
4364 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4365 let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4366 match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4367 Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4368 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4369 }
4370 continue;
4371 }
4372 let folder = std::path::Path::new(&e.cwd);
4373 let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4374 let name = match (in_session_dir, folder.file_name()) {
4375 (true, Some(n)) => n.to_string_lossy().into_owned(),
4376 _ => format!("{} #{}", e.provider, e.instance_id),
4377 };
4378 let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4379 let record = match self.index_provider_session(ws, provider, identity, name) {
4380 Ok(r) => r,
4381 Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4382 };
4383 let cur = self.next_instance_id.load(Ordering::Acquire);
4384 if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4385 let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4386 let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4387 match r {
4388 Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4389 Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4390 }
4391 }
4392 }
4393
4394 fn refresh_session_journal(&self) {
4395 let Some(file) = self.session_resume_journal() else { return };
4396 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4397 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4398 crate::pty_resume::refresh(&file, &homes);
4399 let ids: Vec<(u64, String)> = {
4400 let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4401 records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4402 };
4403 for (inst, sid) in ids {
4404 crate::pty_resume::set_session_id(&file, inst, &sid);
4405 }
4406 }
4407
4408 fn session_grok_leader_socket(
4409 &self,
4410 provider: &AgentId,
4411 mode: SessionMode,
4412 instance_id: AgentInstanceId,
4413 ) -> Option<std::path::PathBuf> {
4414 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4415 return None;
4416 }
4417 let root = self
4418 .m4a_accounts
4419 .as_ref()
4420 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4421 .unwrap_or_else(std::env::temp_dir);
4422 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4423 if let Some(previous) = self
4424 .grok_leaders
4425 .lock()
4426 .unwrap_or_else(|p| p.into_inner())
4427 .insert(instance_id, socket.clone())
4428 {
4429 crate::grok_leader::retire(&previous);
4430 }
4431 Some(socket)
4432 }
4433
4434 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4436 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4437 if let Some(socket) = socket {
4438 crate::grok_leader::retire(&socket);
4439 }
4440 }
4441
4442 fn new_with_incarnation(
4443 handle: Gate4AgentHandle,
4444 access_token: String,
4445 node_id: NodeId,
4446 incarnation_id: NodeIncarnationId,
4447 workspaces: Vec<WorkspaceConfig>,
4448 enabled_providers: Vec<AgentId>,
4449 provider_runtime_statuses: ProviderRuntimeStatuses,
4450 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4451 provider_contracts: Vec<ProviderContractSupport>,
4452 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4453 spawn_profiles: SpawnProfileRegistry,
4454 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4455 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4456 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4457 state_path: Option<PathBuf>,
4458 records: Vec<ManagedSessionRecord>,
4459 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4460 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4461 materializations: Vec<MaterializationOwnershipRecord>,
4462 persistence_warning: Option<String>,
4463 input_settle_timeout_ms: u64,
4464 ) -> Self {
4465 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4466 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4467 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4468 let (harness_mcp_proxy_event_tx, _) =
4469 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4470 let record_providers = records
4471 .iter()
4472 .map(|record| (record.record_id.clone(), record.provider.clone()))
4473 .collect();
4474 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4475 .iter()
4476 .map(|workspace| {
4477 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4478 })
4479 .collect();
4480 for lease in &managed_worktrees {
4481 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4482 }
4483 let managed_worktree_profiles = workspaces.iter()
4484 .map(|workspace| (
4485 workspace.workspace_id.clone(),
4486 workspace.managed_worktree_profiles.clone(),
4487 ))
4488 .collect();
4489 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4490 managed_worktrees,
4491 managed_worktree_tombstones,
4492 ).expect("durable managed worktree registry was validated while loading");
4493 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4494 managed_worktrees.reattach_record_holders(
4495 &managed_worktree_record_holders(&records),
4496 unix_time_ms(),
4497 );
4498 Self {
4499 handle,
4500 access_token,
4501 node_id,
4502 incarnation_id,
4503 started_at_unix_ms: std::time::SystemTime::now()
4504 .duration_since(std::time::UNIX_EPOCH)
4505 .unwrap_or_default()
4506 .as_millis()
4507 .min(u64::MAX as u128) as u64,
4508 workspaces: RwLock::new(
4509 workspaces
4510 .into_iter()
4511 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4512 .collect(),
4513 ),
4514 worktree_service_modes,
4515 managed_worktree_profiles,
4516 managed_worktrees: Mutex::new(managed_worktrees),
4517 enabled_providers,
4518 provider_runtime_statuses,
4519 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4520 provider_runtime_monitor,
4521 provider_contracts,
4522 provider_adapter_contracts,
4523 spawn_profiles,
4524 environment_profiles: RwLock::new(BTreeMap::new()),
4525 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4526 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4527 #[cfg(feature = "dig2-station-probe")]
4528 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4529 bundle_catalog: RwLock::new(BundleCatalog::default()),
4530 delivery_store: Mutex::new(None),
4531 context_pack_store: Mutex::new(None),
4532 context_catalog: RwLock::new(ContextPackCatalog::default()),
4533 native_launch_profile_control,
4534 harness_mcp_registry: None,
4535 native_session_catalog,
4536 session_environment_materializer,
4537 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4538 m4a_live: Mutex::new(BTreeMap::new()),
4539 m4a_keys: Mutex::new(BTreeMap::new()),
4540 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4541 grok_leaders: Mutex::new(BTreeMap::new()),
4542 #[cfg(unix)]
4543 #[cfg(unix)]
4544 signer_proxy: std::sync::OnceLock::new(),
4545 materializations: Mutex::new(
4546 materializations
4547 .into_iter()
4548 .map(|record| (record.id().clone(), record))
4549 .collect(),
4550 ),
4551 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4552 entries: BTreeMap::new(),
4553 }),
4554 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4555 controller: Mutex::new(None),
4556 provisional_spawns: Mutex::new(BTreeMap::new()),
4557 pending_acp_options: Mutex::new(BTreeMap::new()),
4558 history: Mutex::new(NodeEventHistory::new(record_providers)),
4559 event_tx,
4560 terminal_event_tx,
4561 agent_stream_event_tx,
4562 harness_mcp_proxy_event_tx,
4563 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4564 pty_input_wake: tokio::sync::Notify::new(),
4565 pty_input_marks: Mutex::new(BTreeMap::new()),
4566 provider_errors: Mutex::new(crate::provider_error::Tracker::default()),
4567 next_connection_id: AtomicU64::new(1),
4568 next_instance_id: AtomicU64::new(1),
4569 next_command_id: AtomicU64::new(1),
4570 shutdown: AtomicBool::new(false),
4571 shutdown_notify: Notify::new(),
4572 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4573 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4574 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4575 mutation_gate: AsyncMutex::new(()),
4576 session_bindings: Mutex::new(BTreeMap::new()),
4577 session_records: Mutex::new(SessionRecords {
4578 records: records
4579 .into_iter()
4580 .map(|record| (record.record_id.clone(), record))
4581 .collect(),
4582 }),
4583 state_path,
4584 persistence_error: RwLock::new(
4585 persistence_warning
4586 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4587 ),
4588 state_transaction: Mutex::new(()),
4589 input_settle_timeout_ms,
4590 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4591 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4592 shell_efficiency_profile: OnceLock::new(),
4593 native_session_gauge: AtomicUsize::new(0),
4594 connection_loop_iterations: AtomicU64::new(0),
4595 connection_events_sent: AtomicU64::new(0),
4596 rejected_commands_total: AtomicU64::new(0),
4597 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4598 drive_loop_iterations_total: AtomicU64::new(0),
4599 drive_loop_iterations_idle: AtomicU64::new(0),
4600 session_record_retention: SessionRecordRetentionConfig::default(),
4601 retired_records_total: AtomicU64::new(0),
4602 #[cfg(feature = "fixture")]
4603 fixture_semantic_hook_policy: false,
4604 #[cfg(feature = "fixture")]
4605 fixture_spawn_managed_worktree_v2_failure_probe:
4606 SpawnManagedWorktreeV2FailureProbe::default(),
4607 }
4608 }
4609
4610 #[cfg(test)]
4611 fn new(
4612 handle: Gate4AgentHandle,
4613 access_token: String,
4614 node_id: NodeId,
4615 workspaces: Vec<WorkspaceConfig>,
4616 enabled_providers: Vec<AgentId>,
4617 ) -> Self {
4618 Self::new_with_incarnation(
4619 handle,
4620 access_token,
4621 node_id,
4622 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4623 workspaces,
4624 enabled_providers,
4625 ProviderRuntimeStatuses::default(),
4626 None,
4627 Vec::new(),
4628 Vec::new(),
4629 SpawnProfileRegistry::default(),
4630 None,
4631 None,
4632 None,
4633 None,
4634 Vec::new(),
4635 Vec::new(),
4636 Vec::new(),
4637 Vec::new(),
4638 None,
4639 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4640 )
4641 }
4642
4643 fn persistence_error(&self) -> Option<String> {
4644 self.persistence_error
4645 .read()
4646 .unwrap_or_else(|poisoned| poisoned.into_inner())
4647 .clone()
4648 }
4649
4650 async fn admit_provider_runtime(
4651 &self,
4652 provider: &AgentId,
4653 requirement: ProviderRuntimeRequirement,
4654 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4655 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4656 let pipe_transport = monitor.supports_pipe_transport(provider);
4657 let acp_transport = monitor.supports_acp_transport(provider);
4658 let provider = provider.clone();
4659 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4660 let (status, admission) = timeout(
4661 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4662 refresh,
4663 )
4664 .await
4665 .map_err(|_| failure(
4666 NodeFailureCode::BackendBusy,
4667 "provider runtime probe exceeded its bounded deadline",
4668 ))?
4669 .map_err(|_| failure(
4670 NodeFailureCode::BackendOperationFailed,
4671 "provider runtime probe task failed",
4672 ))?;
4673 if let Some(status) = status {
4674 self
4675 .provider_runtime_status_updates
4676 .write()
4677 .unwrap_or_else(|poisoned| poisoned.into_inner())
4678 .insert(status.provider().clone(), status);
4679 }
4680 admission.and_then(|policy| {
4681 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4682 })
4683 } else {
4684 crate::provider_runtime::admit_status(
4690 &self.provider_runtime_statuses,
4691 provider,
4692 false,
4693 false,
4694 requirement,
4695 )
4696 };
4697 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4698 admission.map_err(|error| match error {
4699 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4700 NodeFailureCode::BackendOperationFailed,
4701 "provider launcher is unavailable",
4702 ),
4703 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4704 NodeFailureCode::UnsupportedCapability,
4705 "provider semantic capability is not verified",
4706 ),
4707 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4708 NodeFailureCode::BackendBusy,
4709 "provider runtime probe is already in progress",
4710 ),
4711 })
4712 }
4713
4714 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4720 match self.provider_runtime_monitor.as_deref() {
4721 Some(monitor) => monitor.supports_pipe_transport(provider),
4722 None => false,
4723 }
4724 }
4725
4726 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4732 match self.provider_runtime_monitor.as_deref() {
4733 Some(monitor) => monitor.supports_acp_transport(provider),
4734 None => false,
4735 }
4736 }
4737
4738 fn admit_declared_hook_semantics(
4754 &self,
4755 provider: &AgentId,
4756 policy: ProviderRuntimePolicy,
4757 ) -> ProviderRuntimePolicy {
4758 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4759 return policy;
4760 }
4761 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4762 contract.provider == *provider && contract.family == AdapterFamily::Hook
4763 });
4764 if !declares_hook {
4765 return policy;
4766 }
4767 ProviderRuntimePolicy::new(
4768 policy.raw_pty_lifecycle,
4769 policy.semantic_readiness,
4770 policy.structured_prompt,
4771 policy.provider_session_identity,
4772 policy.semantic_resume,
4773 true,
4774 )
4775 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4776 }
4777
4778 fn resolve_network_allowlist_overlay_args(
4793 &self,
4794 provider: &AgentId,
4795 mode: SessionMode,
4796 approval_level: ApprovalLevel,
4797 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4798 provider_home: Option<&Path>,
4799 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4800 let Some(allowlist_id) = network_allowlist else {
4801 return Ok(Vec::new());
4802 };
4803 let catalog = self
4804 .network_allowlist_catalog
4805 .read()
4806 .unwrap_or_else(|poisoned| poisoned.into_inner());
4807 let Some(entry) = catalog.get(allowlist_id) else {
4808 return Err(failure(
4809 NodeFailureCode::UnknownNetworkAllowlist,
4810 "spawn network allowlist is unavailable on this node",
4811 ));
4812 };
4813
4814 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4816 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4817 |message| {
4818 failure(
4819 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4820 &message,
4821 )
4822 },
4823 )?;
4824 if !matches!(mode, SessionMode::Pty) {
4825 return Err(failure(
4826 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4827 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4828 ));
4829 }
4830 let Some(home) = provider_home else {
4831 return Err(failure(
4832 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4833 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4834 ));
4835 };
4836 let domains =
4837 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4838 if domains.is_empty() {
4839 return Err(failure(
4840 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4841 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4842 ));
4843 }
4844 let settings_path = home.join(
4845 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4846 );
4847 let json =
4848 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4849 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4850 |_| {
4851 failure(
4852 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4853 "Claude station network settings sidecar could not be written under ProviderHome",
4854 )
4855 },
4856 )?;
4857 let overlay =
4858 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4859 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4860 }
4861
4862 let Some(native) = entry.provider_native.as_ref() else {
4863 return Ok(Vec::new());
4864 };
4865 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4866 provider.as_str(),
4867 approval_level,
4868 native,
4869 )
4870 .map_err(|message| {
4871 failure(
4872 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4873 &message,
4874 )
4875 })?;
4876 if overlay.is_empty() {
4877 return Ok(Vec::new());
4878 }
4879 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4880 return Err(failure(
4881 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4882 "Codex network_access -c overlay requires PTY or ACP transport",
4883 ));
4884 }
4885 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4886 }
4887
4888 fn resolve_codex_network_access_overlay_args(
4890 &self,
4891 provider: &AgentId,
4892 mode: SessionMode,
4893 approval_level: ApprovalLevel,
4894 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4895 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4896 self.resolve_network_allowlist_overlay_args(
4897 provider,
4898 mode,
4899 approval_level,
4900 network_allowlist,
4901 None,
4902 )
4903 }
4904
4905 fn resolve_environment_profile(
4906 &self,
4907 resolved: &ResolvedSpawnSpec,
4908 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4909 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4915 let catalog = self
4916 .network_allowlist_catalog
4917 .read()
4918 .unwrap_or_else(|poisoned| poisoned.into_inner());
4919 let Some(entry) = catalog.get(allowlist_id) else {
4920 return Err(failure(
4921 NodeFailureCode::UnknownNetworkAllowlist,
4922 "spawn network allowlist is unavailable on this node",
4923 ));
4924 };
4925 if let Some(native) = entry.provider_native.as_ref() {
4928 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4929 resolved.provider.as_str(),
4930 resolved.approval_level,
4931 native,
4932 ) {
4933 return Err(failure(
4934 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4935 &message,
4936 ));
4937 }
4938 }
4939 }
4940 #[cfg(feature = "dig2-station-probe")]
4949 if resolved.browser_profile_id.is_some() {
4950 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4951 match probe_station_reachable() {
4952 Ok(()) => {}
4953 Err(StationProbeError::UnsupportedPlatform)
4954 | Err(StationProbeError::InvalidPipeSuffix) => {
4955 return Err(failure(
4956 NodeFailureCode::BrowserStationProbeUnavailable,
4957 "dig2browser station probe unavailable on this platform",
4958 ));
4959 }
4960 Err(StationProbeError::Unreachable) => {
4961 return Err(failure(
4962 NodeFailureCode::BrowserStationUnreachable,
4963 "dig2browser station named-pipe is unreachable",
4964 ));
4965 }
4966 }
4967 }
4968 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4969 return Ok(None);
4970 };
4971 let profiles = self
4972 .environment_profiles
4973 .read()
4974 .unwrap_or_else(|poisoned| poisoned.into_inner());
4975 let binding = profiles.get(profile_id).ok_or_else(|| {
4976 failure(
4977 NodeFailureCode::UnknownEnvironmentProfile,
4978 "spawn environment profile is unavailable on this node",
4979 )
4980 })?;
4981 if binding.provider != resolved.provider
4982 || binding.native_profile_id(resolved.mode).is_none()
4983 {
4984 return Err(failure(
4985 NodeFailureCode::EnvironmentProfileBindingMismatch,
4986 "spawn environment profile does not match the provider and mode",
4987 ));
4988 }
4989 Ok(Some(ResolvedEnvironmentProfileReceipt {
4993 profile_id: binding.id.clone(),
4994 profile_revision: binding.revision.clone(),
4995 network_allowlist: resolved.network_allowlist.clone(),
4996 browser_profile_id: resolved.browser_profile_id.clone(),
4997 }))
4998 }
4999
5000 fn resolve_bundle(
5001 &self,
5002 resolved: &ResolvedSpawnSpec,
5003 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5004 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
5005 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
5006 return Ok(None);
5007 };
5008 let catalog = self.bundle_catalog
5009 .read()
5010 .unwrap_or_else(|poisoned| poisoned.into_inner());
5011 let bundle = catalog.get(bundle_id).ok_or_else(|| {
5012 failure(
5013 NodeFailureCode::UnknownBundle,
5014 "spawn bundle is unavailable on this node",
5015 )
5016 })?;
5017 self.bundle_layout(
5018 &resolved.provider,
5019 resolved.mode,
5020 environment_profile,
5021 bundle,
5022 )?;
5023 Ok(Some(bundle.receipt()))
5024 }
5025
5026 fn begin_delivery_stage(
5027 &self,
5028 manifest: DeliveryBundleManifestV2,
5029 ) -> Result<NodeResponse, NodeFailure> {
5030 let mut store = self
5031 .delivery_store
5032 .lock()
5033 .unwrap_or_else(|poisoned| poisoned.into_inner());
5034 let store = store.as_mut().ok_or_else(|| {
5035 failure(
5036 NodeFailureCode::DeliveryStageStorageFailed,
5037 "node delivery store is unavailable",
5038 )
5039 })?;
5040 let begun = store.begin(manifest).map_err(delivery_failure)?;
5041 Ok(NodeResponse::DeliveryStageBegun {
5042 stage_id: begun.stage_id,
5043 manifest_digest: begun.manifest_digest,
5044 missing_blobs: begun.missing_blobs,
5045 })
5046 }
5047
5048 fn put_delivery_blob_chunk(
5049 &self,
5050 stage_id: DeliveryStageId,
5051 blob_digest: DeliveryBlobDigestV1,
5052 offset: u64,
5053 chunk_hex: DeliveryBlobChunkHexV1,
5054 ) -> Result<NodeResponse, NodeFailure> {
5055 let mut store = self
5056 .delivery_store
5057 .lock()
5058 .unwrap_or_else(|poisoned| poisoned.into_inner());
5059 let store = store.as_mut().ok_or_else(|| {
5060 failure(
5061 NodeFailureCode::DeliveryStageStorageFailed,
5062 "node delivery store is unavailable",
5063 )
5064 })?;
5065 let accepted = store
5066 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5067 .map_err(delivery_failure)?;
5068 Ok(NodeResponse::DeliveryBlobChunkAccepted {
5069 stage_id: accepted.stage_id,
5070 blob_digest: accepted.blob_digest,
5071 next_offset: accepted.next_offset,
5072 })
5073 }
5074
5075 fn commit_delivery_stage(
5076 &self,
5077 stage_id: DeliveryStageId,
5078 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5079 let mut store = self
5080 .delivery_store
5081 .lock()
5082 .unwrap_or_else(|poisoned| poisoned.into_inner());
5083 let store = store.as_mut().ok_or_else(|| {
5084 failure(
5085 NodeFailureCode::DeliveryStageStorageFailed,
5086 "node delivery store is unavailable",
5087 )
5088 })?;
5089 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5090 let mut catalog = self
5091 .bundle_catalog
5092 .write()
5093 .unwrap_or_else(|poisoned| poisoned.into_inner());
5094 let mut next_catalog = catalog.clone();
5095 next_catalog
5096 .insert_idempotent(prepared.bundle().clone())
5097 .map_err(|_| {
5098 failure(
5099 NodeFailureCode::DeliveryStageConflict,
5100 "delivery bundle identity conflicts with installed content",
5101 )
5102 })?;
5103 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5104 *catalog = next_catalog;
5105 Ok(receipt)
5106 }
5107
5108 fn abort_delivery_stage(
5109 &self,
5110 stage_id: &DeliveryStageId,
5111 ) -> Result<(), NodeFailure> {
5112 let mut store = self
5113 .delivery_store
5114 .lock()
5115 .unwrap_or_else(|poisoned| poisoned.into_inner());
5116 let store = store.as_mut().ok_or_else(|| {
5117 failure(
5118 NodeFailureCode::DeliveryStageStorageFailed,
5119 "node delivery store is unavailable",
5120 )
5121 })?;
5122 store.abort(stage_id).map_err(delivery_failure)
5123 }
5124
5125 fn resolve_context(
5126 &self,
5127 resolved: &ResolvedSpawnSpec,
5128 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5129 let Some(context_id) = resolved.context_id.as_ref() else {
5130 return Ok(None);
5131 };
5132 self.context_catalog
5133 .read()
5134 .unwrap_or_else(|poisoned| poisoned.into_inner())
5135 .get(context_id)
5136 .map(|context| context.receipt().clone())
5137 .ok_or_else(|| {
5138 failure(
5139 NodeFailureCode::UnknownContextPack,
5140 "spawn context pack is unavailable on this node",
5141 )
5142 })
5143 .map(Some)
5144 }
5145
5146 fn bundle_layout(
5147 &self,
5148 provider: &AgentId,
5149 mode: SessionMode,
5150 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5151 bundle: &NodeBundle,
5152 ) -> Result<BundleProviderLayout, NodeFailure> {
5153 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5154 failure(
5155 NodeFailureCode::BundleBindingMismatch,
5156 "spawn bundle does not match the provider and mode",
5157 )
5158 })?;
5159 if layout != BundleProviderLayout::Codex {
5160 return Ok(layout);
5161 }
5162 let profile = environment_profile.and_then(|receipt| {
5163 let profiles = self
5164 .environment_profiles
5165 .read()
5166 .unwrap_or_else(|poisoned| poisoned.into_inner());
5167 let binding = profiles.get(&receipt.profile_id)?;
5168 if binding.revision != receipt.profile_revision
5169 || &binding.provider != provider
5170 || binding.native_profile_id(mode).is_none()
5171 {
5172 return None;
5173 }
5174 self.environment_materialization_profiles
5175 .read()
5176 .unwrap_or_else(|poisoned| poisoned.into_inner())
5177 .get(&receipt.profile_id)
5178 .cloned()
5179 });
5180 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5181 return Err(failure(
5182 NodeFailureCode::BundleBindingMismatch,
5183 "Codex bundle requires an exact isolated CODEX_HOME profile",
5184 ));
5185 }
5186 Ok(layout)
5187 }
5188
5189 fn select_environment_profile(
5190 &self,
5191 instance_id: AgentInstanceId,
5192 provider: &AgentId,
5193 mode: SessionMode,
5194 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5195 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5196 let Some(environment_profile) = environment_profile else {
5197 return Ok(None);
5198 };
5199 let native_profile_id = {
5200 let profiles = self
5201 .environment_profiles
5202 .read()
5203 .unwrap_or_else(|poisoned| poisoned.into_inner());
5204 let binding = profiles
5205 .get(&environment_profile.profile_id)
5206 .ok_or_else(|| {
5207 failure(
5208 NodeFailureCode::UnknownEnvironmentProfile,
5209 "session environment profile is unavailable on this node",
5210 )
5211 })?;
5212 if &binding.revision != &environment_profile.profile_revision
5213 || &binding.provider != provider
5214 {
5215 return Err(failure(
5216 NodeFailureCode::EnvironmentProfileBindingMismatch,
5217 "session environment profile revision or provider changed",
5218 ));
5219 }
5220 binding.native_profile_id(mode).cloned().ok_or_else(|| {
5221 failure(
5222 NodeFailureCode::EnvironmentProfileBindingMismatch,
5223 "session environment profile does not support this mode",
5224 )
5225 })?
5226 };
5227 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5228 failure(
5229 NodeFailureCode::BackendOperationFailed,
5230 "native environment profile controller is unavailable",
5231 )
5232 })?;
5233 control
5234 .select_native_launch_profile(instance_id, native_profile_id)
5235 .map_err(|_| {
5236 failure(
5237 NodeFailureCode::BackendOperationFailed,
5238 "native environment profile selection failed",
5239 )
5240 })?;
5241 Ok(Some(NativeEnvironmentSelectionGuard {
5242 control: Some(control),
5243 instance_id,
5244 }))
5245 }
5246
5247 fn install_prepared_launch_overlay(
5248 &self,
5249 instance_id: AgentInstanceId,
5250 overlay: PreparedNativeLaunchOverlay,
5251 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5252 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5253 failure(
5254 NodeFailureCode::BackendOperationFailed,
5255 "native launch profile controller is unavailable",
5256 )
5257 })?;
5258 match overlay {
5259 PreparedNativeLaunchOverlay::Environment(overlay) => {
5260 control
5261 .install_native_launch_environment_overlay(instance_id, overlay)
5262 .map_err(|_| failure(
5263 NodeFailureCode::BackendOperationFailed,
5264 "session environment overlay installation failed",
5265 ))?;
5266 Ok(None)
5267 }
5268 PreparedNativeLaunchOverlay::Instance(overlay) => {
5269 control
5270 .install_native_instance_launch_overlay(instance_id, overlay)
5271 .map_err(|_| failure(
5272 NodeFailureCode::BundleBindingMismatch,
5273 "session bundle launch overlay installation failed",
5274 ))?;
5275 Ok(Some(NativeInstanceOverlayGuard {
5276 control: Some(control),
5277 instance_id,
5278 }))
5279 }
5280 }
5281 }
5282
5283 fn environment_materialization_profile(
5284 &self,
5285 environment_profile: &ResolvedEnvironmentProfileReceipt,
5286 ) -> Option<NodeSessionMaterializationProfile> {
5287 self.environment_materialization_profiles
5288 .read()
5289 .unwrap_or_else(|poisoned| poisoned.into_inner())
5290 .get(&environment_profile.profile_id)
5291 .cloned()
5292 }
5293
5294 fn materialization_profile(
5295 &self,
5296 provider: &AgentId,
5297 mode: SessionMode,
5298 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5299 bundle: Option<&ResolvedBundleReceipt>,
5300 context: Option<&ResolvedContextPackReceipt>,
5301 ) -> Result<Option<(
5302 NodeSessionMaterializationProfile,
5303 bool,
5304 Option<BundleProviderLayout>,
5305 )>, NodeFailure> {
5306 let environment = environment_profile
5307 .and_then(|receipt| self.environment_materialization_profile(receipt));
5308 let (bundle, bundle_layout) = match bundle {
5309 Some(receipt) => {
5310 let catalog = self.bundle_catalog.read()
5311 .unwrap_or_else(|poisoned| poisoned.into_inner());
5312 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5313 NodeFailureCode::UnknownBundle,
5314 "session bundle is unavailable on this node",
5315 ))?;
5316 if installed.receipt() != *receipt {
5317 return Err(failure(
5318 NodeFailureCode::BundleBindingMismatch,
5319 "session bundle revision or digest changed",
5320 ));
5321 }
5322 let layout = self.bundle_layout(
5323 provider,
5324 mode,
5325 environment_profile,
5326 installed,
5327 )?;
5328 (Some(installed.clone()), Some(layout))
5329 }
5330 None => (None, None),
5331 };
5332 let has_environment_overlay = environment.is_some() || context.is_some();
5333 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5334 (None, None, None) => None,
5335 (Some(profile), None, None) => Some(Ok(profile)),
5336 (None, Some(bundle), Some(layout)) => {
5337 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5338 }
5339 (Some(profile), Some(bundle), Some(layout)) => {
5340 Some(profile.with_bundle(bundle, layout))
5341 }
5342 _ => {
5343 return Err(failure(
5344 NodeFailureCode::BundleBindingMismatch,
5345 "session bundle provider layout is unavailable",
5346 ));
5347 }
5348 };
5349 let profile = profile
5350 .transpose()
5351 .map_err(|_| failure(
5352 NodeFailureCode::BundleMaterializationFailed,
5353 "session bundle materialization profile is invalid",
5354 ))?;
5355 let profile = match (profile, context) {
5356 (Some(profile), Some(receipt)) => {
5357 let catalog = self.context_catalog
5358 .read()
5359 .unwrap_or_else(|poisoned| poisoned.into_inner());
5360 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5361 NodeFailureCode::UnknownContextPack,
5362 "session context pack is unavailable on this node",
5363 ))?;
5364 if pack.receipt() != receipt {
5365 return Err(failure(
5366 NodeFailureCode::ContextPackMaterializationFailed,
5367 "session context pack identity changed",
5368 ));
5369 }
5370 profile.with_context(pack)
5371 }
5372 (None, Some(receipt)) => {
5373 let catalog = self.context_catalog
5374 .read()
5375 .unwrap_or_else(|poisoned| poisoned.into_inner());
5376 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5377 NodeFailureCode::UnknownContextPack,
5378 "session context pack is unavailable on this node",
5379 ))?;
5380 if pack.receipt() != receipt {
5381 return Err(failure(
5382 NodeFailureCode::ContextPackMaterializationFailed,
5383 "session context pack identity changed",
5384 ));
5385 }
5386 NodeSessionMaterializationProfile::from_context(pack)
5387 }
5388 (Some(profile), None) => Ok(profile),
5389 (None, None) => return Ok(None),
5390 }
5391 .map_err(|_| failure(
5392 NodeFailureCode::ContextPackMaterializationFailed,
5393 "session context pack materialization profile is invalid",
5394 ))?;
5395 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5396 }
5397
5398 fn prepare_session_materialization<'a>(
5399 &'a self,
5400 address: &SessionAddress,
5401 provider: &AgentId,
5402 mode: SessionMode,
5403 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5404 bundle: Option<&ResolvedBundleReceipt>,
5405 context: Option<&ResolvedContextPackReceipt>,
5406 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5407 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5408 let Some((profile, has_environment_overlay, bundle_layout)) =
5409 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5410 else {
5411 return Ok(None);
5412 };
5413 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5414 failure(
5415 NodeFailureCode::BackendOperationFailed,
5416 "session environment materializer is unavailable",
5417 )
5418 })?;
5419 let materialization_failure_code = if context.is_some() {
5420 NodeFailureCode::ContextPackMaterializationFailed
5421 } else if bundle.is_some() {
5422 NodeFailureCode::BundleMaterializationFailed
5423 } else {
5424 NodeFailureCode::BackendOperationFailed
5425 };
5426 let id = MaterializationId::new(format!(
5427 "mat-{}-{}-{}",
5428 self.incarnation_id,
5429 address.session.instance_id.0,
5430 address.session.generation.0,
5431 ))
5432 .map_err(|_| {
5433 failure(
5434 NodeFailureCode::BackendOperationFailed,
5435 "session environment materialization identity failed",
5436 )
5437 })?;
5438 let owner = MaterializationOwner::Session {
5439 incarnation_id: self.incarnation_id,
5440 instance_id: address.session.instance_id,
5441 generation: address.session.generation,
5442 };
5443 let mut ownership = materializer
5444 .begin(
5445 id.clone(),
5446 environment_profile.cloned(),
5447 bundle.cloned(),
5448 context.cloned(),
5449 owner,
5450 managed_lease_id,
5451 &profile,
5452 unix_time_ms(),
5453 )
5454 .map_err(|_| {
5455 failure(
5456 materialization_failure_code,
5457 "session materialization preparation failed",
5458 )
5459 })?;
5460 {
5461 let _transaction = self
5462 .state_transaction
5463 .lock()
5464 .unwrap_or_else(|poisoned| poisoned.into_inner());
5465 let duplicate = self
5466 .materializations
5467 .lock()
5468 .unwrap_or_else(|poisoned| poisoned.into_inner())
5469 .insert(id.clone(), ownership.clone());
5470 if duplicate.is_some() {
5471 return Err(failure(
5472 NodeFailureCode::BackendOperationFailed,
5473 "session environment materialization identity conflict",
5474 ));
5475 }
5476 if let Err(error) = self.persist_state_locked() {
5477 self.materializations
5478 .lock()
5479 .unwrap_or_else(|poisoned| poisoned.into_inner())
5480 .remove(&id);
5481 return Err(persistence_failure(error));
5482 }
5483 }
5484 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5485 Ok(environment) => environment,
5486 Err(_) => {
5487 let transaction = self
5488 .state_transaction
5489 .lock()
5490 .unwrap_or_else(|poisoned| poisoned.into_inner());
5491 self.materializations
5492 .lock()
5493 .unwrap_or_else(|poisoned| poisoned.into_inner())
5494 .insert(id.clone(), ownership.clone());
5495 if self.persist_state_locked().is_err() {
5496 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5497 }
5498 drop(transaction);
5499 if ownership.state() == MaterializationState::CleanupRequired {
5500 let _ = self.cleanup_materialization(&id);
5501 }
5502 return Err(failure(
5503 materialization_failure_code,
5504 "session materialization failed",
5505 ));
5506 }
5507 };
5508 {
5509 let _transaction = self
5510 .state_transaction
5511 .lock()
5512 .unwrap_or_else(|poisoned| poisoned.into_inner());
5513 self.materializations
5514 .lock()
5515 .unwrap_or_else(|poisoned| poisoned.into_inner())
5516 .insert(id.clone(), ownership.clone());
5517 if let Err(error) = self.persist_state_locked() {
5518 drop(_transaction);
5519 let _ = self.cleanup_materialization(&id);
5520 return Err(persistence_failure(error));
5521 }
5522 }
5523 let guard = SessionMaterializationGuard {
5524 shared: self,
5525 id: Some(id),
5526 provider_home: Some(ownership.provider_home().to_path_buf()),
5527 };
5528 let bundle_arguments = match bundle {
5529 Some(receipt) => {
5530 let catalog = self.bundle_catalog.read()
5531 .unwrap_or_else(|poisoned| poisoned.into_inner());
5532 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5533 NodeFailureCode::UnknownBundle,
5534 "session bundle is unavailable on this node",
5535 ))?;
5536 if installed.receipt() != *receipt {
5537 return Err(failure(
5538 NodeFailureCode::BundleBindingMismatch,
5539 "session bundle revision or digest changed",
5540 ));
5541 }
5542 let layout = bundle_layout.ok_or_else(|| failure(
5543 NodeFailureCode::BundleBindingMismatch,
5544 "materialized bundle provider layout is unavailable",
5545 ))?;
5546 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5547 failure(
5548 NodeFailureCode::BundleMaterializationFailed,
5549 "session bundle byte revalidation failed",
5550 )
5551 })?;
5552 Some(bundle_launch_arguments(
5553 layout,
5554 ownership.bundle_root(),
5555 ownership.provider_home(),
5556 ).map_err(|_| failure(
5557 NodeFailureCode::BundleBindingMismatch,
5558 "session bundle launch binding is invalid",
5559 ))?)
5560 }
5561 None => None,
5562 };
5563 if let Some(receipt) = context {
5564 materializer
5565 .revalidate_context(&ownership, receipt)
5566 .map_err(|_| failure(
5567 NodeFailureCode::ContextPackMaterializationFailed,
5568 "session context pack byte revalidation failed",
5569 ))?;
5570 }
5571 let transport = match mode {
5572 SessionMode::Pty => TransportKind::Pty,
5573 SessionMode::Inline => TransportKind::Pipe,
5574 SessionMode::Acp => TransportKind::Acp,
5575 };
5576 let overlay = if let Some(extra_args) = bundle_arguments {
5577 Some(PreparedNativeLaunchOverlay::Instance(
5578 NativeInstanceLaunchOverlay::new(
5579 provider.clone(),
5580 transport,
5581 if has_environment_overlay {
5582 environment
5583 } else {
5584 Vec::new()
5585 },
5586 extra_args,
5587 )
5588 .map_err(|_| {
5589 failure(
5590 NodeFailureCode::BundleBindingMismatch,
5591 "session bundle launch overlay is invalid",
5592 )
5593 })?,
5594 ))
5595 } else if has_environment_overlay {
5596 let context_only = environment_profile.is_none()
5597 && bundle.is_none()
5598 && context.is_some();
5599 let overlay = if context_only {
5600 NativeLaunchEnvironmentOverlay::new_context_root(
5601 provider.clone(),
5602 transport,
5603 environment,
5604 )
5605 .map_err(|_| {
5606 failure(
5607 NodeFailureCode::ContextPackMaterializationFailed,
5608 "context-only launch overlay is invalid",
5609 )
5610 })?
5611 } else {
5612 NativeLaunchEnvironmentOverlay::new(
5613 provider.clone(),
5614 transport,
5615 environment,
5616 )
5617 .map_err(|_| {
5618 failure(
5619 NodeFailureCode::BackendOperationFailed,
5620 "session environment overlay is invalid",
5621 )
5622 })?
5623 };
5624 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5625 } else {
5626 None
5627 };
5628 Ok(Some((overlay, guard)))
5629 }
5630
5631 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5632 let Some(mut ownership) = self
5633 .materializations
5634 .lock()
5635 .unwrap_or_else(|poisoned| poisoned.into_inner())
5636 .get(id)
5637 .cloned()
5638 else {
5639 return Ok(());
5640 };
5641 if ownership.state() != MaterializationState::CleanupRequired {
5642 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5643 failure(
5644 NodeFailureCode::BackendOperationFailed,
5645 "session environment cleanup state is invalid",
5646 )
5647 })?;
5648 }
5649 {
5650 let _transaction = self
5651 .state_transaction
5652 .lock()
5653 .unwrap_or_else(|poisoned| poisoned.into_inner());
5654 let previous = self
5655 .materializations
5656 .lock()
5657 .unwrap_or_else(|poisoned| poisoned.into_inner())
5658 .insert(id.clone(), ownership.clone());
5659 if let Err(error) = self.persist_state_locked() {
5660 let mut materializations = self
5661 .materializations
5662 .lock()
5663 .unwrap_or_else(|poisoned| poisoned.into_inner());
5664 if let Some(previous) = previous {
5665 materializations.insert(id.clone(), previous);
5666 }
5667 return Err(persistence_failure(error));
5668 }
5669 }
5670 let cleanup = self
5671 .session_environment_materializer
5672 .as_ref()
5673 .ok_or(())
5674 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5675 if cleanup.is_err() {
5676 let _ = ownership.mark_recovery_required(unix_time_ms());
5677 self.materializations
5678 .lock()
5679 .unwrap_or_else(|poisoned| poisoned.into_inner())
5680 .insert(id.clone(), ownership);
5681 if self.persist_state().is_err() {
5682 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5683 }
5684 return Err(failure(
5685 NodeFailureCode::BackendOperationFailed,
5686 "session environment cleanup requires recovery",
5687 ));
5688 }
5689 let _transaction = self
5690 .state_transaction
5691 .lock()
5692 .unwrap_or_else(|poisoned| poisoned.into_inner());
5693 self.materializations
5694 .lock()
5695 .unwrap_or_else(|poisoned| poisoned.into_inner())
5696 .remove(id);
5697 if let Err(error) = self.persist_state_locked() {
5698 self.materializations
5699 .lock()
5700 .unwrap_or_else(|poisoned| poisoned.into_inner())
5701 .insert(id.clone(), ownership);
5702 return Err(persistence_failure(error));
5703 }
5704 Ok(())
5705 }
5706
5707 fn resolve_record_materialization(
5708 &self,
5709 record_id: &SessionRecordId,
5710 provider: &AgentId,
5711 mode: SessionMode,
5712 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5713 bundle: Option<&ResolvedBundleReceipt>,
5714 context: Option<&ResolvedContextPackReceipt>,
5715 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5716 let ownership = {
5717 let materializations = self
5718 .materializations
5719 .lock()
5720 .unwrap_or_else(|poisoned| poisoned.into_inner());
5721 let mut matches = materializations.values().filter(|record| {
5722 record.owner()
5723 == &MaterializationOwner::Record {
5724 record_id: record_id.clone(),
5725 }
5726 });
5727 let ownership = matches.next().cloned();
5728 if matches.next().is_some() {
5729 return Err(failure(
5730 NodeFailureCode::BackendOperationFailed,
5731 "managed session has conflicting environment ownership",
5732 ));
5733 }
5734 ownership
5735 };
5736 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5737 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5738 failure(
5739 NodeFailureCode::ContextPackMaterializationFailed,
5740 "session context materializer is unavailable",
5741 )
5742 })?;
5743 let pack = materializer
5744 .revalidate_context(ownership, receipt)
5745 .map_err(|_| {
5746 self.mark_materialization_recovery_required(ownership.id());
5747 failure(
5748 NodeFailureCode::ContextPackMaterializationFailed,
5749 "managed session context pack revalidation failed",
5750 )
5751 })?;
5752 self.context_catalog
5753 .write()
5754 .unwrap_or_else(|poisoned| poisoned.into_inner())
5755 .insert(pack)
5756 .map_err(|_| failure(
5757 NodeFailureCode::ContextPackMaterializationFailed,
5758 "managed session context pack catalog restore failed",
5759 ))?;
5760 }
5761 let materialization = self.materialization_profile(
5762 provider,
5763 mode,
5764 environment_profile,
5765 bundle,
5766 context,
5767 )?;
5768 match (ownership, materialization) {
5769 (None, None) => Ok(None),
5770 (None, Some(_)) | (Some(_), None) => Err(failure(
5771 NodeFailureCode::BackendOperationFailed,
5772 "managed session materialization is unavailable",
5773 )),
5774 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5775 if ownership.environment_profile() != environment_profile
5776 || ownership.bundle() != bundle
5777 || ownership.context() != context
5778 || ownership.state() != MaterializationState::Ready
5779 {
5780 self.mark_materialization_recovery_required(ownership.id());
5781 return Err(failure(
5782 NodeFailureCode::BackendOperationFailed,
5783 "managed session environment ownership requires recovery",
5784 ));
5785 }
5786 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5787 failure(
5788 NodeFailureCode::BackendOperationFailed,
5789 "session environment materializer is unavailable",
5790 )
5791 })?;
5792 if materializer.revalidate(&ownership).is_err() {
5793 self.mark_materialization_recovery_required(ownership.id());
5794 return Err(failure(
5795 NodeFailureCode::BackendOperationFailed,
5796 "managed session environment revalidation failed",
5797 ));
5798 }
5799 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5800 let catalog = self.bundle_catalog.read()
5801 .unwrap_or_else(|poisoned| poisoned.into_inner());
5802 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5803 return Err(failure(
5804 NodeFailureCode::UnknownBundle,
5805 "managed session bundle is unavailable",
5806 ));
5807 };
5808 let layout = bundle_layout.ok_or_else(|| failure(
5809 NodeFailureCode::BundleBindingMismatch,
5810 "record bundle provider layout is unavailable",
5811 ))?;
5812 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5813 drop(catalog);
5814 self.mark_materialization_recovery_required(ownership.id());
5815 return Err(failure(
5816 NodeFailureCode::BundleMaterializationFailed,
5817 "managed session bundle revalidation failed",
5818 ));
5819 }
5820 Some(bundle_launch_arguments(
5821 layout,
5822 ownership.bundle_root(),
5823 ownership.provider_home(),
5824 ).map_err(|_| failure(
5825 NodeFailureCode::BundleBindingMismatch,
5826 "managed session bundle launch binding is invalid",
5827 ))?)
5828 } else {
5829 None
5830 };
5831 if !has_environment_materialization {
5832 let overlay = bundle_arguments.map(|extra_args| {
5833 NativeInstanceLaunchOverlay::new(
5834 provider.clone(),
5835 TransportKind::Pty,
5836 Vec::new(),
5837 extra_args,
5838 )
5839 .map(PreparedNativeLaunchOverlay::Instance)
5840 .map_err(|_| failure(
5841 NodeFailureCode::BundleBindingMismatch,
5842 "managed session bundle launch overlay is invalid",
5843 ))
5844 }).transpose()?;
5845 return Ok(Some((ownership.id().clone(), overlay)));
5846 }
5847 let environment = match materializer.resolve_environment(&ownership, &profile) {
5848 Ok(environment) => environment,
5849 Err(
5850 SessionEnvironmentMaterializeError::SecretUnavailable
5851 | SessionEnvironmentMaterializeError::SecretDenied
5852 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5853 ) => {
5854 return Err(failure(
5855 NodeFailureCode::BackendOperationFailed,
5856 "managed session environment resolution failed",
5857 ));
5858 }
5859 Err(
5860 SessionEnvironmentMaterializeError::InvalidRoot
5861 | SessionEnvironmentMaterializeError::OwnershipMismatch
5862 | SessionEnvironmentMaterializeError::Filesystem(_)
5863 | SessionEnvironmentMaterializeError::Record(_),
5864 ) => {
5865 self.mark_materialization_recovery_required(ownership.id());
5866 return Err(failure(
5867 NodeFailureCode::BackendOperationFailed,
5868 "managed session environment resolution failed",
5869 ));
5870 }
5871 };
5872 let transport = match mode {
5873 SessionMode::Pty => TransportKind::Pty,
5874 SessionMode::Inline => TransportKind::Pipe,
5875 SessionMode::Acp => TransportKind::Acp,
5876 };
5877 let overlay = match bundle_arguments {
5878 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5879 NativeInstanceLaunchOverlay::new(
5880 provider.clone(),
5881 transport,
5882 environment,
5883 extra_args,
5884 )
5885 .map_err(|_| failure(
5886 NodeFailureCode::BundleBindingMismatch,
5887 "managed session bundle launch overlay is invalid",
5888 ))?,
5889 ),
5890 None => PreparedNativeLaunchOverlay::Environment(
5891 NativeLaunchEnvironmentOverlay::new(
5892 provider.clone(),
5893 transport,
5894 environment,
5895 )
5896 .map_err(|_| {
5897 failure(
5898 NodeFailureCode::BackendOperationFailed,
5899 "managed session environment overlay is invalid",
5900 )
5901 })?,
5902 ),
5903 };
5904 Ok(Some((ownership.id().clone(), Some(overlay))))
5905 }
5906 }
5907 }
5908
5909 fn destroy_record_materialization(
5910 &self,
5911 record_id: &SessionRecordId,
5912 ) -> Result<bool, NodeFailure> {
5913 let ids = self
5914 .materializations
5915 .lock()
5916 .unwrap_or_else(|poisoned| poisoned.into_inner())
5917 .values()
5918 .filter_map(|record| {
5919 (record.owner()
5920 == &MaterializationOwner::Record {
5921 record_id: record_id.clone(),
5922 })
5923 .then(|| record.id().clone())
5924 })
5925 .collect::<Vec<_>>();
5926 if ids.len() > 1 {
5927 return Err(failure(
5928 NodeFailureCode::BackendOperationFailed,
5929 "managed session has conflicting environment ownership",
5930 ));
5931 }
5932 let Some(id) = ids.first().cloned() else {
5933 return Ok(false);
5934 };
5935 let (terminal_record, mut cleanup_ownership) = {
5936 let _transaction = self
5937 .state_transaction
5938 .lock()
5939 .unwrap_or_else(|poisoned| poisoned.into_inner());
5940 let original_record = self.record(record_id)?;
5941 let mut terminal_record = original_record.clone();
5942 terminal_record.provider_session = None;
5943 terminal_record.active_session = None;
5944 terminal_record.state = ManagedSessionState::Unavailable;
5945 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5946 terminal_record.updated_at_unix_ms = unix_time_ms();
5947 let original_ownership = self
5948 .materializations
5949 .lock()
5950 .unwrap_or_else(|poisoned| poisoned.into_inner())
5951 .get(&id)
5952 .cloned()
5953 .ok_or_else(|| {
5954 failure(
5955 NodeFailureCode::BackendOperationFailed,
5956 "managed session environment ownership disappeared during cleanup",
5957 )
5958 })?;
5959 let mut cleanup_ownership = original_ownership.clone();
5960 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5961 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5962 failure(
5963 NodeFailureCode::BackendOperationFailed,
5964 "managed session environment cleanup state is invalid",
5965 )
5966 })?;
5967 }
5968 self.session_records
5969 .lock()
5970 .unwrap_or_else(|poisoned| poisoned.into_inner())
5971 .records
5972 .insert(record_id.clone(), terminal_record.clone());
5973 self.materializations
5974 .lock()
5975 .unwrap_or_else(|poisoned| poisoned.into_inner())
5976 .insert(id.clone(), cleanup_ownership.clone());
5977 if let Err(error) = self.persist_state_locked() {
5978 self.session_records
5979 .lock()
5980 .unwrap_or_else(|poisoned| poisoned.into_inner())
5981 .records
5982 .insert(record_id.clone(), original_record);
5983 self.materializations
5984 .lock()
5985 .unwrap_or_else(|poisoned| poisoned.into_inner())
5986 .insert(id.clone(), original_ownership);
5987 return Err(persistence_failure(error));
5988 }
5989 (terminal_record, cleanup_ownership)
5990 };
5991 let cleanup = self
5992 .session_environment_materializer
5993 .as_ref()
5994 .ok_or(())
5995 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5996 if cleanup.is_err() {
5997 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5998 self.materializations
5999 .lock()
6000 .unwrap_or_else(|poisoned| poisoned.into_inner())
6001 .insert(id.clone(), cleanup_ownership);
6002 if self.persist_state().is_err() {
6003 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6004 }
6005 return Err(failure(
6006 NodeFailureCode::BackendOperationFailed,
6007 "managed session environment cleanup requires recovery",
6008 ));
6009 }
6010 let _transaction = self
6011 .state_transaction
6012 .lock()
6013 .unwrap_or_else(|poisoned| poisoned.into_inner());
6014 self.remove_record_memory(record_id);
6015 self.materializations
6016 .lock()
6017 .unwrap_or_else(|poisoned| poisoned.into_inner())
6018 .remove(&id);
6019 if let Err(error) = self.persist_state_locked() {
6020 self.session_records
6021 .lock()
6022 .unwrap_or_else(|poisoned| poisoned.into_inner())
6023 .records
6024 .insert(record_id.clone(), terminal_record);
6025 self.materializations
6026 .lock()
6027 .unwrap_or_else(|poisoned| poisoned.into_inner())
6028 .insert(id, cleanup_ownership);
6029 return Err(persistence_failure(error));
6030 }
6031 Ok(true)
6032 }
6033
6034 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6035 let changed = {
6036 let mut materializations = self
6037 .materializations
6038 .lock()
6039 .unwrap_or_else(|poisoned| poisoned.into_inner());
6040 let Some(ownership) = materializations.get_mut(id) else {
6041 return;
6042 };
6043 ownership.mark_recovery_required(unix_time_ms()).is_ok()
6044 };
6045 if changed && self.persist_state().is_err() {
6046 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6047 }
6048 }
6049
6050 fn reconcile_materializations(&self) {
6051 let records = self
6052 .materializations
6053 .lock()
6054 .unwrap_or_else(|poisoned| poisoned.into_inner())
6055 .values()
6056 .cloned()
6057 .collect::<Vec<_>>();
6058 for mut ownership in records {
6059 let result = match (ownership.state(), ownership.owner()) {
6060 (MaterializationState::Preparing, _)
6061 | (MaterializationState::CleanupRequired, _) => {
6062 self.cleanup_materialization(ownership.id())
6063 }
6064 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6065 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6066 self.materializations
6067 .lock()
6068 .unwrap_or_else(|poisoned| poisoned.into_inner())
6069 .insert(ownership.id().clone(), ownership.clone());
6070 self.persist_state().map_err(persistence_failure)
6071 } else {
6072 Err(failure(
6073 NodeFailureCode::BackendOperationFailed,
6074 "session environment reconciliation failed",
6075 ))
6076 }
6077 }
6078 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6079 .session_environment_materializer
6080 .as_ref()
6081 .ok_or_else(|| {
6082 failure(
6083 NodeFailureCode::BackendOperationFailed,
6084 "session environment materializer is unavailable",
6085 )
6086 })
6087 .and_then(|materializer| {
6088 materializer.revalidate(&ownership).map_err(|_| failure(
6089 NodeFailureCode::BackendOperationFailed,
6090 "session materialization revalidation failed",
6091 ))?;
6092 let record = self.record(record_id)?;
6093 if record.environment_profile.as_ref() != ownership.environment_profile()
6094 || record.bundle.as_ref() != ownership.bundle()
6095 || record.context.as_ref() != ownership.context()
6096 {
6097 return Err(failure(
6098 NodeFailureCode::BackendOperationFailed,
6099 "session materialization ownership changed after restart",
6100 ));
6101 }
6102 if let Some(receipt) = ownership.bundle() {
6103 let catalog = self.bundle_catalog.read()
6104 .unwrap_or_else(|poisoned| poisoned.into_inner());
6105 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6106 NodeFailureCode::UnknownBundle,
6107 "session bundle is unavailable after restart",
6108 ))?;
6109 if bundle.receipt() != *receipt {
6110 return Err(failure(
6111 NodeFailureCode::BundleBindingMismatch,
6112 "session bundle changed after restart",
6113 ));
6114 }
6115 let layout = self.bundle_layout(
6116 &record.provider,
6117 record.mode,
6118 record.environment_profile.as_ref(),
6119 bundle,
6120 )?;
6121 materializer
6122 .revalidate_bundle(&ownership, bundle, layout)
6123 .map_err(|_| failure(
6124 NodeFailureCode::BundleMaterializationFailed,
6125 "session bundle revalidation failed",
6126 ))?;
6127 }
6128 if let Some(receipt) = ownership.context() {
6129 let pack = materializer
6130 .revalidate_context(&ownership, receipt)
6131 .map_err(|_| {
6132 failure(
6133 NodeFailureCode::ContextPackMaterializationFailed,
6134 "session context revalidation failed",
6135 )
6136 })?;
6137 self.context_catalog
6138 .write()
6139 .unwrap_or_else(|poisoned| poisoned.into_inner())
6140 .insert(pack)
6141 .map_err(|_| {
6142 failure(
6143 NodeFailureCode::ContextPackMaterializationFailed,
6144 "session context catalog restore failed",
6145 )
6146 })?;
6147 }
6148 Ok(())
6149 }),
6150 (MaterializationState::RecoveryRequired, _) => Ok(()),
6151 };
6152 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6153 let _ = ownership.mark_recovery_required(unix_time_ms());
6154 self.materializations
6155 .lock()
6156 .unwrap_or_else(|poisoned| poisoned.into_inner())
6157 .insert(ownership.id().clone(), ownership);
6158 if self.persist_state().is_err() {
6159 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6160 }
6161 }
6162 }
6163 let materialized_profiles = self
6164 .environment_materialization_profiles
6165 .read()
6166 .unwrap_or_else(|poisoned| poisoned.into_inner())
6167 .keys()
6168 .cloned()
6169 .collect::<Vec<_>>();
6170 let ownership = self
6171 .materializations
6172 .lock()
6173 .unwrap_or_else(|poisoned| poisoned.into_inner())
6174 .values()
6175 .cloned()
6176 .collect::<Vec<_>>();
6177 let changed = {
6178 let mut session_records = self
6179 .session_records
6180 .lock()
6181 .unwrap_or_else(|poisoned| poisoned.into_inner());
6182 let mut changed = false;
6183 for record in session_records.records.values_mut() {
6184 let environment_profile = record.environment_profile.as_ref();
6185 if environment_profile.is_none()
6186 && record.bundle.is_none()
6187 && record.context.is_none()
6188 {
6189 continue;
6190 }
6191 if record.bundle.is_none()
6192 && environment_profile.is_some_and(|profile| {
6193 !materialized_profiles.contains(&profile.profile_id)
6194 })
6195 {
6196 continue;
6197 }
6198 let has_exact_ownership = ownership.iter().any(|candidate| {
6199 candidate.state() == MaterializationState::Ready
6200 && candidate.environment_profile() == environment_profile
6201 && candidate.bundle() == record.bundle.as_ref()
6202 && candidate.context() == record.context.as_ref()
6203 && candidate.owner()
6204 == &MaterializationOwner::Record {
6205 record_id: record.record_id.clone(),
6206 }
6207 });
6208 if !has_exact_ownership {
6209 record.provider_session = None;
6210 record.active_session = None;
6211 record.state = ManagedSessionState::Unavailable;
6212 record.last_error = Some(if record.bundle.is_some() {
6213 "bundle-unavailable".to_owned()
6214 } else if record.context.is_some() {
6215 "context-unavailable".to_owned()
6216 } else {
6217 "environment-profile-unavailable".to_owned()
6218 });
6219 record.updated_at_unix_ms = unix_time_ms();
6220 changed = true;
6221 }
6222 }
6223 changed
6224 };
6225 if changed && self.persist_state().is_err() {
6226 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6227 }
6228 }
6229
6230 fn replay_spawn_spec(
6231 &self,
6232 spec: &SpawnSpec,
6233 now: Instant,
6234 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6235 let mut cache = self
6236 .spawn_idempotency
6237 .lock()
6238 .unwrap_or_else(|poisoned| poisoned.into_inner());
6239 cache.entries.retain(|_, entry| entry.expires_at > now);
6240 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6241 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6242 return Err(failure(
6243 NodeFailureCode::SpawnIdempotencyConflict,
6244 "spawn idempotency key was reused across spawn kinds",
6245 ));
6246 };
6247 if existing != spec {
6248 return Err(failure(
6249 NodeFailureCode::SpawnIdempotencyConflict,
6250 "spawn idempotency key was reused with a different specification",
6251 ));
6252 }
6253 return Ok(Some(result.clone()));
6254 }
6255 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6256 return Err(failure(
6257 NodeFailureCode::SpawnIdempotencyCapacity,
6258 "spawn idempotency cache reached its bounded capacity",
6259 ));
6260 }
6261 Ok(None)
6262 }
6263
6264 fn remember_spawn_spec(
6265 &self,
6266 spec: SpawnSpec,
6267 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6268 accepted_at: Instant,
6269 ) {
6270 self
6271 .spawn_idempotency
6272 .lock()
6273 .unwrap_or_else(|poisoned| poisoned.into_inner())
6274 .entries
6275 .insert(
6276 spec.idempotency_key.clone(),
6277 SpawnIdempotencyEntry {
6278 value: SpawnIdempotencyValue::Standard { spec, result },
6279 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6280 },
6281 );
6282 }
6283
6284 fn claim_harness_mcp_spawn(
6285 &self,
6286 _reservation_id: &HarnessMcpReservationId,
6287 _activation_digest: &HarnessMcpActivationDigest,
6288 spec: &SpawnSpec,
6289 now: Instant,
6290 ) -> Result<(), NodeFailure> {
6291 let mut cache = self.spawn_idempotency.lock()
6292 .unwrap_or_else(|poisoned| poisoned.into_inner());
6293 cache.entries.retain(|_, entry| entry.expires_at > now);
6294 if cache.entries.contains_key(&spec.idempotency_key) {
6295 return Err(failure(
6296 NodeFailureCode::SpawnIdempotencyConflict,
6297 "spawn idempotency key was reused across spawn kinds",
6298 ));
6299 }
6300 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6301 return Err(failure(
6302 NodeFailureCode::SpawnIdempotencyCapacity,
6303 "spawn idempotency cache reached its bounded capacity",
6304 ));
6305 }
6306 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6307 value: SpawnIdempotencyValue::HarnessMcp,
6308 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6309 });
6310 Ok(())
6311 }
6312
6313 fn replay_managed_spawn(
6314 &self,
6315 request: &ManagedWorktreeSpawnRequest,
6316 now: Instant,
6317 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6318 let mut cache = self.spawn_idempotency.lock()
6319 .unwrap_or_else(|poisoned| poisoned.into_inner());
6320 cache.entries.retain(|_, entry| entry.expires_at > now);
6321 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6322 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6323 return Err(failure(
6324 NodeFailureCode::SpawnIdempotencyConflict,
6325 "spawn idempotency key was reused across spawn kinds",
6326 ));
6327 };
6328 if existing != request {
6329 return Err(failure(
6330 NodeFailureCode::SpawnIdempotencyConflict,
6331 "spawn idempotency key was reused with a different managed specification",
6332 ));
6333 }
6334 return Ok(Some(result.clone()));
6335 }
6336 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6337 return Err(failure(
6338 NodeFailureCode::SpawnIdempotencyCapacity,
6339 "spawn idempotency cache reached its bounded capacity",
6340 ));
6341 }
6342 Ok(None)
6343 }
6344
6345 fn remember_managed_spawn(
6346 &self,
6347 request: ManagedWorktreeSpawnRequest,
6348 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6349 accepted_at: Instant,
6350 ) {
6351 self.spawn_idempotency.lock()
6352 .unwrap_or_else(|poisoned| poisoned.into_inner())
6353 .entries
6354 .insert(
6355 request.spawn_spec.idempotency_key.clone(),
6356 SpawnIdempotencyEntry {
6357 value: SpawnIdempotencyValue::Managed { request, result },
6358 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6359 },
6360 );
6361 }
6362
6363 fn remember_managed_spawn_attempt(
6364 &self,
6365 durable_v2_key: Option<&SpawnIdempotencyKey>,
6366 request: ManagedWorktreeSpawnRequest,
6367 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6368 accepted_at: Instant,
6369 ) {
6370 if durable_v2_key.is_none() {
6371 self.remember_managed_spawn(request, result, accepted_at);
6372 }
6373 }
6374
6375 fn acquire_durable_managed_spawn_v2(
6376 &self,
6377 request: &ManagedWorktreeSpawnRequestV2,
6378 request_digest: String,
6379 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6380 if self.state_path.is_none() {
6381 return Err(failure(
6382 NodeFailureCode::BackendOperationFailed,
6383 "managed worktree V2 requires durable node state",
6384 ));
6385 }
6386 let _transaction = self.state_transaction
6387 .lock()
6388 .unwrap_or_else(|poisoned| poisoned.into_inner());
6389 let key = request.spawn_spec.idempotency_key.clone();
6390 let mut previous = None;
6391 {
6392 let mut replays = self.managed_spawn_replays
6393 .lock()
6394 .unwrap_or_else(|poisoned| poisoned.into_inner());
6395 if let Some(record) = replays.get_mut(&key) {
6396 if record.request_digest != request_digest {
6397 return Err(failure(
6398 NodeFailureCode::SpawnIdempotencyConflict,
6399 "managed worktree V2 idempotency key was reused with a different request",
6400 ));
6401 }
6402 match &record.state {
6403 ManagedWorktreeSpawnReplayStateV10::Pending {
6404 owner_incarnation_id,
6405 lease_id: None,
6406 } if owner_incarnation_id != &self.incarnation_id => {
6407 previous = Some(record.clone());
6408 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6409 owner_incarnation_id: self.incarnation_id,
6410 lease_id: None,
6411 };
6412 record.updated_at_unix_ms = unix_time_ms();
6413 }
6414 ManagedWorktreeSpawnReplayStateV10::Pending {
6415 lease_id: None,
6416 ..
6417 } => {
6418 return Err(failure(
6419 NodeFailureCode::BackendBusy,
6420 "managed worktree V2 reservation is already active",
6421 ));
6422 }
6423 ManagedWorktreeSpawnReplayStateV10::Pending {
6424 lease_id: Some(_),
6425 ..
6426 } => {
6427 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6428 }
6429 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6430 return Ok(DurableManagedSpawnReplayDecision::Committed(
6431 receipt.clone(),
6432 ));
6433 }
6434 }
6435 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6436 return Err(failure(
6437 NodeFailureCode::SpawnIdempotencyCapacity,
6438 "durable managed worktree replay ledger reached its bounded capacity",
6439 ));
6440 } else {
6441 let now = unix_time_ms();
6442 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6443 idempotency_key: key.clone(),
6444 request_digest,
6445 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6446 profile_id: request.worktree_profile_id.clone(),
6447 expected_profile_revision: request.expected_profile_revision.clone(),
6448 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6449 owner_incarnation_id: self.incarnation_id,
6450 lease_id: None,
6451 },
6452 created_at_unix_ms: now,
6453 updated_at_unix_ms: now,
6454 });
6455 }
6456 }
6457 if let Err(error) = self.persist_state_locked() {
6458 let mut replays = self.managed_spawn_replays
6459 .lock()
6460 .unwrap_or_else(|poisoned| poisoned.into_inner());
6461 match previous {
6462 Some(previous) => {
6463 replays.insert(key, previous);
6464 }
6465 None => {
6466 replays.remove(&key);
6467 }
6468 }
6469 return Err(persistence_failure(error));
6470 }
6471 Ok(DurableManagedSpawnReplayDecision::Reserved)
6472 }
6473
6474 fn link_durable_managed_spawn_v2_lease_locked(
6475 &self,
6476 key: &SpawnIdempotencyKey,
6477 lease_id: &ManagedWorktreeLeaseId,
6478 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6479 let mut replays = self.managed_spawn_replays
6480 .lock()
6481 .unwrap_or_else(|poisoned| poisoned.into_inner());
6482 let record = replays.get_mut(key).ok_or_else(|| {
6483 failure(
6484 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6485 "managed worktree V2 reservation disappeared before allocation",
6486 )
6487 })?;
6488 let previous = record.clone();
6489 match &mut record.state {
6490 ManagedWorktreeSpawnReplayStateV10::Pending {
6491 lease_id: current,
6492 ..
6493 }
6494 if current.is_none() =>
6495 {
6496 *current = Some(lease_id.clone());
6497 record.updated_at_unix_ms = unix_time_ms();
6498 Ok(previous)
6499 }
6500 _ => Err(failure(
6501 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6502 "managed worktree V2 reservation is not allocation-ready",
6503 )),
6504 }
6505 }
6506
6507 fn restore_durable_managed_spawn_v2_locked(
6508 &self,
6509 key: &SpawnIdempotencyKey,
6510 previous: ManagedWorktreeSpawnReplayRecordV10,
6511 ) {
6512 self.managed_spawn_replays
6513 .lock()
6514 .unwrap_or_else(|poisoned| poisoned.into_inner())
6515 .insert(key.clone(), previous);
6516 }
6517
6518 fn commit_durable_managed_spawn_v2(
6519 &self,
6520 request: &ManagedWorktreeSpawnRequestV2,
6521 receipt: ManagedWorktreeSpawnReceipt,
6522 ) -> Result<(), NodeFailure> {
6523 let _transaction = self.state_transaction
6524 .lock()
6525 .unwrap_or_else(|poisoned| poisoned.into_inner());
6526 let key = &request.spawn_spec.idempotency_key;
6527 let previous = {
6528 let mut replays = self.managed_spawn_replays
6529 .lock()
6530 .unwrap_or_else(|poisoned| poisoned.into_inner());
6531 let record = replays.get_mut(key).ok_or_else(|| {
6532 failure(
6533 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6534 "managed worktree V2 reservation disappeared before commit",
6535 )
6536 })?;
6537 let previous = record.clone();
6538 match &record.state {
6539 ManagedWorktreeSpawnReplayStateV10::Pending {
6540 lease_id: Some(lease_id),
6541 ..
6542 } if lease_id == &receipt.lease.lease_id => {}
6543 _ => {
6544 return Err(failure(
6545 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6546 "managed worktree V2 reservation is not linked to the spawned lease",
6547 ));
6548 }
6549 }
6550 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6551 record.updated_at_unix_ms = unix_time_ms();
6552 previous
6553 };
6554 if let Err(error) = self.persist_state_locked() {
6555 self.restore_durable_managed_spawn_v2_locked(key, previous);
6556 return Err(persistence_failure(error));
6557 }
6558 Ok(())
6559 }
6560
6561 fn remove_unallocated_durable_managed_spawn_v2(
6562 &self,
6563 request: &ManagedWorktreeSpawnRequestV2,
6564 ) -> Result<(), NodeFailure> {
6565 let _transaction = self.state_transaction
6566 .lock()
6567 .unwrap_or_else(|poisoned| poisoned.into_inner());
6568 let key = &request.spawn_spec.idempotency_key;
6569 let removed = {
6570 let mut replays = self.managed_spawn_replays
6571 .lock()
6572 .unwrap_or_else(|poisoned| poisoned.into_inner());
6573 match replays.get(key).map(|record| &record.state) {
6574 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6575 lease_id: None,
6576 ..
6577 }) => {
6578 replays.remove(key)
6579 }
6580 _ => None,
6581 }
6582 };
6583 let Some(removed) = removed else {
6584 return Ok(());
6585 };
6586 if let Err(error) = self.persist_state_locked() {
6587 self.managed_spawn_replays
6588 .lock()
6589 .unwrap_or_else(|poisoned| poisoned.into_inner())
6590 .insert(key.clone(), removed);
6591 return Err(persistence_failure(error));
6592 }
6593 Ok(())
6594 }
6595
6596 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6597 if spec.target.node_id != self.node_id {
6598 return Err(failure(
6599 NodeFailureCode::SpawnTargetMismatch,
6600 "spawn target does not match this node",
6601 ));
6602 }
6603 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6604 failure(
6605 NodeFailureCode::UnknownSpawnProfile,
6606 "spawn profile is unavailable on this node",
6607 )
6608 })?;
6609 let resolved = spec.resolve(defaults).map_err(|error| match error {
6610 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6611 NodeFailureCode::SpawnProfileRevisionMismatch,
6612 "spawn profile revision does not match the loaded profile",
6613 ),
6614 _ => failure(
6615 NodeFailureCode::InvalidRequest,
6616 "spawn specification could not be resolved",
6617 ),
6618 })?;
6619 if resolved.target.worktree_id.is_some() {
6620 self.require_worktree_service(&resolved.target.workspace_id)?;
6621 }
6622 let environment_profile = self.resolve_environment_profile(&resolved)?;
6623 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6624 self.resolve_context(&resolved)?;
6625 Ok(resolved)
6626 }
6627
6628 async fn resolve_spawn_workspace(
6629 &self,
6630 resolved: &ResolvedSpawnSpec,
6631 deadline: Instant,
6632 ) -> Result<WorkspaceId, NodeFailure> {
6633 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6634 return Ok(resolved.target.workspace_id.clone());
6635 };
6636 if self.managed_worktrees.lock()
6637 .unwrap_or_else(|poisoned| poisoned.into_inner())
6638 .lease_for_workspace(worktree_id)
6639 .is_some()
6640 {
6641 return Err(failure(
6642 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6643 "managed worktree targets may only be spawned through their lease request",
6644 ));
6645 }
6646 self.require_worktree_service(&resolved.target.workspace_id)?;
6647 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6648 let selected_root = self.workspace_root(worktree_id)?;
6649 let remaining = spawn_deadline_remaining(deadline)?;
6650 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6651 .await
6652 .map_err(|_| {
6653 failure(
6654 NodeFailureCode::SpawnDeadlineExceeded,
6655 "Git worktree selection exceeded the spawn deadline",
6656 )
6657 })?
6658 .map_err(git_worktree_failure)?;
6659 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6660 Ok(worktree_id.clone())
6661 }
6662
6663 async fn spawn_from_spec(
6664 &self,
6665 spec: SpawnSpec,
6666 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6667 self.spawn_from_spec_with_proxy(spec, None).await
6668 }
6669
6670 async fn spawn_from_spec_with_proxy(
6671 &self,
6672 spec: SpawnSpec,
6673 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6674 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6675 let accepted_at = Instant::now();
6676 if harness_mcp.is_none() {
6677 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6678 return replayed;
6679 }
6680 }
6681 let resolved = self.resolve_spawn_spec(&spec)?;
6682 let environment_profile = self.resolve_environment_profile(&resolved)?;
6683 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6684 let context = self.resolve_context(&resolved)?;
6685 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6686 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6687 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6688 let spawn_workspace_id = match spawn_workspace_id {
6689 Ok(workspace_id) => workspace_id,
6690 Err(error) => {
6691 let result = Err(error);
6692 if harness_mcp.is_none() {
6693 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6694 }
6695 return result;
6696 }
6697 };
6698 if self.managed_worktrees.lock()
6699 .unwrap_or_else(|poisoned| poisoned.into_inner())
6700 .lease_for_workspace(&spawn_workspace_id)
6701 .is_some()
6702 {
6703 let result = Err(failure(
6704 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6705 "managed worktree targets may only be spawned through their lease request",
6706 ));
6707 if harness_mcp.is_none() {
6708 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6709 }
6710 return result;
6711 }
6712 let result = self
6713 .spawn_session_with_deadline(
6714 spawn_workspace_id,
6715 resolved.provider.clone(),
6716 resolved.mode,
6717 resolved.terminal_size,
6718 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6719 environment_profile.clone(),
6720 bundle.clone(),
6721 context.clone(),
6722 None,
6723 None,
6724 SpawnRecordPolicy::Always,
6725 Some(deadline),
6726 &required_capabilities,
6727 harness_mcp,
6728 resolved.approval_level,
6729 resolved.network_allowlist.clone(),
6730 resolved.browser_profile_id.clone(),
6731 )
6732 .await
6733 .map(|(session, _runtime_policy)| {
6734 resolved.receipt_with_materialization(
6735 self.incarnation_id,
6736 session,
6737 environment_profile,
6738 bundle,
6739 context,
6740 )
6741 });
6742 if harness_mcp.is_none() {
6743 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6744 }
6745 result
6746 }
6747
6748 async fn spawn_from_spec_with_harness_mcp(
6749 &self,
6750 reservation_id: HarnessMcpReservationId,
6751 activation_digest: HarnessMcpActivationDigest,
6752 spec: SpawnSpec,
6753 deadline_unix_ms: u64,
6754 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6755 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6756 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6757 })?;
6758 if let Some(receipt) = registry
6759 .replay_spawn(&reservation_id, &activation_digest, &spec)
6760 .map_err(harness_mcp_failure)?
6761 {
6762 return Ok(receipt);
6763 }
6764 let resolved = self.resolve_spawn_spec(&spec)?;
6765 let prepared = registry
6766 .prepare_spawn(
6767 &reservation_id,
6768 &activation_digest,
6769 &spec,
6770 deadline_unix_ms,
6771 resolved.provider.clone(),
6772 )
6773 .map_err(harness_mcp_failure)?;
6774 self.claim_harness_mcp_spawn(
6775 &reservation_id,
6776 &activation_digest,
6777 &spec,
6778 Instant::now(),
6779 )?;
6780 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6781 let mut receipt = match spawn {
6782 Ok(receipt) => receipt,
6783 Err(error) => {
6784 let _ = registry.abort(&reservation_id, &activation_digest);
6785 return Err(error);
6786 }
6787 };
6788 let record_id = self
6789 .session_bindings
6790 .lock()
6791 .unwrap_or_else(|poisoned| poisoned.into_inner())
6792 .get(&receipt.session.session.instance_id)
6793 .and_then(|binding| binding.record_id.clone());
6794 let record_id = match record_id {
6795 Some(record_id) => record_id,
6796 None => {
6797 let rollback = self.remove_session(&receipt.session).await;
6798 let _ = registry.abort(&reservation_id, &activation_digest);
6799 if let Err(rollback_error) = rollback {
6800 return Err(rollback_error);
6801 }
6802 return Err(failure(
6803 NodeFailureCode::BindingMismatch,
6804 "harness MCP spawn did not retain its exact managed record",
6805 ));
6806 }
6807 };
6808 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6809 reservation_id: reservation_id.clone(),
6810 activation_digest: activation_digest.clone(),
6811 });
6812 if let Err(error) = registry.mark_spawned(
6813 &prepared,
6814 receipt.session.clone(),
6815 record_id,
6816 receipt.clone(),
6817 ) {
6818 let rollback = self.remove_session(&receipt.session).await;
6819 let _ = registry.abort(&reservation_id, &activation_digest);
6820 if let Err(rollback_error) = rollback {
6821 return Err(rollback_error);
6822 }
6823 return Err(harness_mcp_failure(error));
6824 }
6825 Ok(receipt)
6826 }
6827
6828 async fn spawn_managed_worktree(
6829 &self,
6830 request: ManagedWorktreeSpawnRequest,
6831 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6832 self.spawn_managed_worktree_inner(request, None).await
6833 }
6834
6835 async fn spawn_managed_worktree_inner(
6836 &self,
6837 request: ManagedWorktreeSpawnRequest,
6838 durable_v2_key: Option<&SpawnIdempotencyKey>,
6839 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6840 let accepted_at = Instant::now();
6841 if durable_v2_key.is_none() {
6842 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6843 return replayed;
6844 }
6845 }
6846 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6847 if request.spawn_spec.target.worktree_id.is_some() {
6848 let result = Err(failure(
6849 NodeFailureCode::InvalidRequest,
6850 "managed worktree spawn must not select a caller-provided worktree",
6851 ));
6852 self.remember_managed_spawn_attempt(
6853 durable_v2_key,
6854 request,
6855 result.clone(),
6856 accepted_at,
6857 );
6858 return result;
6859 }
6860 let environment_profile = self.resolve_environment_profile(&resolved)?;
6861 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6862 let context = self.resolve_context(&resolved)?;
6863 let source_workspace_id = resolved.target.workspace_id.clone();
6864 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6865 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6866 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6867 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6868 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6869 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6870 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6871 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6872 };
6873 let runtime_policy = timeout(
6874 spawn_deadline_remaining(deadline)?,
6875 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6876 ).await.map_err(|_| failure(
6877 NodeFailureCode::SpawnDeadlineExceeded,
6878 "provider admission exceeded the managed spawn deadline",
6879 ))??;
6880 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6881 return Err(failure(
6882 NodeFailureCode::UnsupportedSpawnCapability,
6883 "provider runtime does not admit a required spawn capability",
6884 ));
6885 }
6886 self.ensure_binding_capacity()?;
6887 let source_root = self.workspace_root(&source_workspace_id)?;
6888 profile.validate_for_workspace(&source_root)
6889 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6890 let base_timeout_ms = u64::try_from(
6891 spawn_deadline_remaining(deadline)?.as_millis(),
6892 ).unwrap_or(u64::MAX).max(1);
6893 let base_commit = resolve_base_commit_with_timeout(
6894 &source_root,
6895 profile.base(),
6896 base_timeout_ms,
6897 ).await.map_err(|error| {
6898 if Instant::now() >= deadline || error.message.contains("timed out") {
6899 failure(
6900 NodeFailureCode::SpawnDeadlineExceeded,
6901 "managed worktree base resolution exceeded the spawn deadline",
6902 )
6903 } else {
6904 managed_git_worktree_failure(error)
6905 }
6906 })?;
6907
6908 let lease = {
6909 let _transaction = self.state_transaction.lock()
6910 .unwrap_or_else(|poisoned| poisoned.into_inner());
6911 let previous = self.managed_worktrees.lock()
6912 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6913 let lease = self.managed_worktrees.lock()
6914 .unwrap_or_else(|poisoned| poisoned.into_inner())
6915 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6916 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6917 let previous_replay = match durable_v2_key
6918 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6919 .transpose()
6920 {
6921 Ok(previous) => previous,
6922 Err(error) => {
6923 *self.managed_worktrees.lock()
6924 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6925 return Err(error);
6926 }
6927 };
6928 if let Err(error) = self.persist_state_locked() {
6929 *self.managed_worktrees.lock()
6930 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6931 if let (Some(key), Some(previous_replay)) =
6932 (durable_v2_key, previous_replay)
6933 {
6934 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6935 }
6936 return Err(persistence_failure(error));
6937 }
6938 lease
6939 };
6940 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6941 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6942 self.managed_worktrees.lock()
6943 .unwrap_or_else(|poisoned| poisoned.into_inner())
6944 .remove_unmutated(&lease.lease_id);
6945 if self.persist_state().is_err() {
6946 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6947 }
6948 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6949 self.remember_managed_spawn_attempt(
6950 durable_v2_key,
6951 request,
6952 result.clone(),
6953 accepted_at,
6954 );
6955 return result;
6956 }
6957 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6958 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6959 Err(error) => {
6960 self.reconcile_failed_allocation(&lease, &source_root).await;
6961 let result = Err(error);
6962 self.remember_managed_spawn_attempt(
6963 durable_v2_key,
6964 request,
6965 result.clone(),
6966 accepted_at,
6967 );
6968 return result;
6969 }
6970 };
6971 let created = match create_git_worktree_with_timeout(
6972 &source_root,
6973 &lease.target_root,
6974 &lease.branch,
6975 Some(&base_commit),
6976 mutation_timeout_ms,
6977 ).await {
6978 Ok(created) => created,
6979 Err(error) => {
6980 self.reconcile_failed_allocation(&lease, &source_root).await;
6981 let result = Err(if Instant::now() >= deadline
6982 || error.message.contains("timed out")
6983 || error.message.contains("deadline")
6984 {
6985 failure(
6986 NodeFailureCode::SpawnDeadlineExceeded,
6987 "managed worktree creation exceeded the spawn deadline",
6988 )
6989 } else {
6990 managed_git_worktree_failure(error)
6991 });
6992 self.remember_managed_spawn_attempt(
6993 durable_v2_key,
6994 request,
6995 result.clone(),
6996 accepted_at,
6997 );
6998 return result;
6999 }
7000 };
7001 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
7002 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
7003 let result = Err(failure(
7004 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7005 "created worktree identity did not match its durable lease",
7006 ));
7007 self.remember_managed_spawn_attempt(
7008 durable_v2_key,
7009 request,
7010 result.clone(),
7011 accepted_at,
7012 );
7013 return result;
7014 }
7015 {
7016 let mut registry = self.managed_worktrees.lock()
7017 .unwrap_or_else(|poisoned| poisoned.into_inner());
7018 let current = registry.get_mut(&lease.lease_id)
7019 .expect("allocated managed worktree lease remains present");
7020 current.expected_head = Some(created.head.clone());
7021 current.state = ManagedWorktreeLeaseState::Ready;
7022 current.updated_at_unix_ms = unix_time_ms();
7023 }
7024 if let Err(error) = self.persist_state() {
7025 self.mark_managed_recovery_required(
7026 &lease.lease_id,
7027 ManagedWorktreeCleanupFailure::Backend,
7028 );
7029 let result = Err(persistence_failure(error));
7030 self.remember_managed_spawn_attempt(
7031 durable_v2_key,
7032 request,
7033 result.clone(),
7034 accepted_at,
7035 );
7036 return result;
7037 }
7038 let ready = self.managed_worktrees.lock()
7039 .unwrap_or_else(|poisoned| poisoned.into_inner())
7040 .get(&lease.lease_id)
7041 .expect("ready managed worktree lease remains present")
7042 .snapshot();
7043 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7044 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7045 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7046 let result = Err(error);
7047 self.remember_managed_spawn_attempt(
7048 durable_v2_key,
7049 request,
7050 result.clone(),
7051 accepted_at,
7052 );
7053 return result;
7054 }
7055 let spawn = self.spawn_session_with_deadline(
7056 lease.workspace_id.clone(),
7057 resolved.provider.clone(),
7058 resolved.mode,
7059 resolved.terminal_size,
7060 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7061 environment_profile.clone(),
7062 bundle.clone(),
7063 context.clone(),
7064 Some(lease.lease_id.clone()),
7065 Some(runtime_policy),
7066 SpawnRecordPolicy::Always,
7067 Some(deadline),
7068 &required_capabilities,
7069 None,
7070 resolved.approval_level,
7071 resolved.network_allowlist.clone(),
7072 resolved.browser_profile_id.clone(),
7073 ).await;
7074 let (session, effective_runtime_policy) = match spawn {
7075 Ok(spawned) => spawned,
7076 Err(error) => {
7077 if self
7078 .cleanup_managed_worktree(&lease.lease_id, true)
7079 .await
7080 .is_err()
7081 {
7082 self.mark_managed_recovery_required(
7083 &lease.lease_id,
7084 ManagedWorktreeCleanupFailure::Backend,
7085 );
7086 }
7087 let result = Err(error);
7088 self.remember_managed_spawn_attempt(
7089 durable_v2_key,
7090 request,
7091 result.clone(),
7092 accepted_at,
7093 );
7094 return result;
7095 }
7096 };
7097 let (record_id, raw_inventory_record_id) = {
7098 let mut bindings = self.session_bindings.lock()
7099 .unwrap_or_else(|poisoned| poisoned.into_inner());
7100 let binding = bindings.get_mut(&session.session.instance_id)
7101 .expect("successful spawn retains its session binding");
7102 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7103 managed_spawn_record_ownership(binding, effective_runtime_policy)
7104 };
7105 let bound = self.managed_worktrees.lock()
7106 .unwrap_or_else(|poisoned| poisoned.into_inner())
7107 .bind_session(
7108 &lease.lease_id,
7109 ManagedWorktreeSessionHolder {
7110 incarnation_id: self.incarnation_id,
7111 instance_id: session.session.instance_id,
7112 generation: session.session.generation,
7113 },
7114 record_id,
7115 unix_time_ms(),
7116 )
7117 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7118 let snapshot = match bound {
7119 Ok(snapshot) => snapshot,
7120 Err(error) => {
7121 if raw_inventory_record_id
7122 .as_ref()
7123 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7124 {
7125 self.mark_managed_recovery_required(
7126 &lease.lease_id,
7127 ManagedWorktreeCleanupFailure::Backend,
7128 );
7129 }
7130 let _ = self.remove_session(&session).await;
7131 self.mark_managed_recovery_required(
7132 &lease.lease_id,
7133 ManagedWorktreeCleanupFailure::Backend,
7134 );
7135 let result = Err(error);
7136 self.remember_managed_spawn_attempt(
7137 durable_v2_key,
7138 request,
7139 result.clone(),
7140 accepted_at,
7141 );
7142 return result;
7143 }
7144 };
7145 if let Err(error) = self.persist_state() {
7146 if raw_inventory_record_id
7147 .as_ref()
7148 .is_some_and(|record_id| self.discard_record(record_id).is_err())
7149 {
7150 self.mark_managed_recovery_required(
7151 &lease.lease_id,
7152 ManagedWorktreeCleanupFailure::Backend,
7153 );
7154 }
7155 let _ = self.remove_session(&session).await;
7156 if self
7157 .cleanup_managed_worktree(&lease.lease_id, true)
7158 .await
7159 .is_err()
7160 {
7161 self.mark_managed_recovery_required(
7162 &lease.lease_id,
7163 ManagedWorktreeCleanupFailure::Backend,
7164 );
7165 }
7166 let result = Err(persistence_failure(error));
7167 self.remember_managed_spawn_attempt(
7168 durable_v2_key,
7169 request,
7170 result.clone(),
7171 accepted_at,
7172 );
7173 return result;
7174 }
7175 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7176 let receipt = managed_spawn_receipt(
7177 &resolved,
7178 self.incarnation_id,
7179 session,
7180 snapshot,
7181 environment_profile,
7182 bundle,
7183 context,
7184 );
7185 let result = Ok(receipt);
7186 self.remember_managed_spawn_attempt(
7187 durable_v2_key,
7188 request,
7189 result.clone(),
7190 accepted_at,
7191 );
7192 result
7193 }
7194
7195 async fn spawn_managed_worktree_v2(
7196 &self,
7197 request: ManagedWorktreeSpawnRequestV2,
7198 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7199 if request.spawn_spec.target.node_id != self.node_id {
7200 return Err(failure(
7201 NodeFailureCode::SpawnTargetMismatch,
7202 "spawn target does not match this node",
7203 ));
7204 }
7205 if request.spawn_spec.target.worktree_id.is_some() {
7206 return Err(failure(
7207 NodeFailureCode::InvalidRequest,
7208 "managed worktree spawn must not select a caller-provided worktree",
7209 ));
7210 }
7211 let request_digest = managed_spawn_request_digest_v2(&request)?;
7212 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7213 match replay {
7214 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7215 DurableManagedSpawnReplayDecision::PendingLinked => {
7216 return Err(failure(
7217 NodeFailureCode::ManagedWorktreeRecoveryRequired,
7218 "managed worktree V2 reservation has an incomplete durable allocation",
7219 ));
7220 }
7221 DurableManagedSpawnReplayDecision::Reserved => {}
7222 }
7223 let validated = (|| {
7224 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7225 let profile = self.managed_profile(
7226 &resolved.target.workspace_id,
7227 &request.worktree_profile_id,
7228 )?;
7229 if profile.revision() != &request.expected_profile_revision {
7230 return Err(failure(
7231 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7232 "managed worktree profile revision does not match the loaded profile",
7233 ));
7234 }
7235 Ok(())
7236 })();
7237 match validated {
7238 Ok(()) => {}
7239 Err(error) => {
7240 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7241 return Err(error);
7242 }
7243 }
7244 let legacy = ManagedWorktreeSpawnRequest {
7245 spawn_spec: request.spawn_spec.clone(),
7246 worktree_profile_id: request.worktree_profile_id.clone(),
7247 };
7248 match self
7249 .spawn_managed_worktree_inner(
7250 legacy,
7251 Some(&request.spawn_spec.idempotency_key),
7252 )
7253 .await
7254 {
7255 Ok(receipt) => {
7256 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7257 Ok(receipt)
7258 }
7259 Err(error) => {
7260 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7261 Err(error)
7262 }
7263 }
7264 }
7265
7266 fn managed_profile(
7267 &self,
7268 workspace_id: &WorkspaceId,
7269 profile_id: &WorktreeProfileId,
7270 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7271 if self.worktree_service_modes.get(workspace_id).copied()
7272 != Some(WorktreeServiceMode::Managed)
7273 {
7274 return Err(failure(
7275 NodeFailureCode::UnsupportedSpawnCapability,
7276 "workspace does not enable managed worktrees",
7277 ));
7278 }
7279 self.managed_worktree_profiles.get(workspace_id)
7280 .and_then(|profiles| profiles.get(profile_id))
7281 .cloned()
7282 .ok_or_else(|| failure(
7283 NodeFailureCode::InvalidRequest,
7284 "managed worktree profile is unavailable for this workspace",
7285 ))
7286 }
7287
7288 async fn reconcile_failed_allocation(
7289 &self,
7290 lease: &ManagedWorktreeLeaseRecord,
7291 source_root: &str,
7292 ) {
7293 match list_git_worktrees(source_root).await {
7294 Ok(worktrees) => {
7295 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7296 None => {
7297 match std::fs::symlink_metadata(&lease.target_root) {
7298 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7299 self.managed_worktrees.lock()
7300 .unwrap_or_else(|poisoned| poisoned.into_inner())
7301 .remove_unmutated(&lease.lease_id);
7302 let _ = self.persist_state();
7303 }
7304 _ => self.mark_managed_recovery_required(
7305 &lease.lease_id,
7306 ManagedWorktreeCleanupFailure::OwnershipConflict,
7307 ),
7308 }
7309 }
7310 Some(item) if exact_owned_worktree(
7311 lease,
7312 &item.path,
7313 item.branch.as_deref(),
7314 &item.head,
7315 ) => {
7316 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7317 }
7318 Some(_) => self.mark_managed_recovery_required(
7319 &lease.lease_id,
7320 ManagedWorktreeCleanupFailure::OwnershipConflict,
7321 ),
7322 }
7323 }
7324 Err(_) => self.mark_managed_recovery_required(
7325 &lease.lease_id,
7326 ManagedWorktreeCleanupFailure::Backend,
7327 ),
7328 }
7329 }
7330
7331 async fn reconcile_managed_worktrees(&self) {
7332 let leases = self.managed_worktrees.lock()
7333 .unwrap_or_else(|poisoned| poisoned.into_inner())
7334 .active_records()
7335 .cloned()
7336 .collect::<Vec<_>>();
7337 for lease in leases {
7338 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7339 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7340 _ => {
7341 self.mark_managed_recovery_required(
7342 &lease.lease_id,
7343 ManagedWorktreeCleanupFailure::OwnershipConflict,
7344 );
7345 continue;
7346 }
7347 };
7348 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7349 self.mark_managed_recovery_required(
7350 &lease.lease_id,
7351 ManagedWorktreeCleanupFailure::OwnershipConflict,
7352 );
7353 continue;
7354 }
7355 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7356 Ok(root) => root,
7357 Err(_) => {
7358 self.mark_managed_recovery_required(
7359 &lease.lease_id,
7360 ManagedWorktreeCleanupFailure::OwnershipConflict,
7361 );
7362 continue;
7363 }
7364 };
7365 let worktrees = match list_git_worktrees(&source_root).await {
7366 Ok(worktrees) => worktrees,
7367 Err(_) => {
7368 self.mark_managed_recovery_required(
7369 &lease.lease_id,
7370 ManagedWorktreeCleanupFailure::Backend,
7371 );
7372 continue;
7373 }
7374 };
7375 let listed = worktrees.iter()
7376 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7377 let Some(listed) = listed else {
7378 match std::fs::symlink_metadata(&lease.target_root) {
7379 Err(error) if error.kind() == io::ErrorKind::NotFound
7380 && lease.state == ManagedWorktreeLeaseState::Allocating
7381 && !lease.has_holders() => {
7382 let removed = self.managed_worktrees.lock()
7383 .unwrap_or_else(|poisoned| poisoned.into_inner())
7384 .tombstone(&lease.lease_id, unix_time_ms());
7385 if self.persist_state().is_ok() && removed.is_some() {
7386 self.publish(NodeEvent::ManagedWorktreeRemoved {
7387 lease_id: lease.lease_id.clone(),
7388 });
7389 }
7390 }
7391 _ => self.mark_managed_recovery_required(
7392 &lease.lease_id,
7393 ManagedWorktreeCleanupFailure::OwnershipConflict,
7394 ),
7395 }
7396 continue;
7397 };
7398 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7399 &lease,
7400 &listed.path,
7401 listed.branch.as_deref(),
7402 &listed.head,
7403 ) {
7404 self.mark_managed_recovery_required(
7405 &lease.lease_id,
7406 ManagedWorktreeCleanupFailure::OwnershipConflict,
7407 );
7408 continue;
7409 }
7410 if !self.workspaces.read()
7411 .unwrap_or_else(|poisoned| poisoned.into_inner())
7412 .contains_key(&lease.workspace_id)
7413 {
7414 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7415 self.mark_managed_recovery_required(
7416 &lease.lease_id,
7417 ManagedWorktreeCleanupFailure::Backend,
7418 );
7419 continue;
7420 }
7421 }
7422 let snapshot = {
7423 let mut registry = self.managed_worktrees.lock()
7424 .unwrap_or_else(|poisoned| poisoned.into_inner());
7425 let current = registry.get_mut(&lease.lease_id)
7426 .expect("reconciled managed lease remains present");
7427 current.expected_head = Some(listed.head.clone());
7428 current.cleanup_failure = None;
7429 current.state = if current.has_holders() {
7430 ManagedWorktreeLeaseState::InUse
7431 } else if current.retention == ManagedWorktreeRetention::Retain {
7432 ManagedWorktreeLeaseState::Retained
7433 } else {
7434 ManagedWorktreeLeaseState::Ready
7435 };
7436 current.updated_at_unix_ms = unix_time_ms();
7437 current.snapshot()
7438 };
7439 if self.persist_state().is_err() {
7440 self.mark_managed_recovery_required(
7441 &lease.lease_id,
7442 ManagedWorktreeCleanupFailure::Backend,
7443 );
7444 continue;
7445 }
7446 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7447 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7448 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7449 }
7450 }
7451 }
7452
7453 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7473 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7474 .session_records
7475 .lock()
7476 .unwrap_or_else(|poisoned| poisoned.into_inner())
7477 .records
7478 .values()
7479 .filter(|record| {
7480 record.state == ManagedSessionState::Live
7481 && record.exported_context.is_none()
7482 && record.provider_session.is_some()
7483 })
7484 .filter_map(|record| {
7485 record
7486 .active_session
7487 .clone()
7488 .map(|session| (record.record_id.clone(), session))
7489 })
7490 .collect();
7491 for (record_id, session) in candidates {
7492 let shared = Arc::clone(self);
7493 tokio::spawn(async move {
7494 shared
7495 .reconcile_context_pack_export_for_record(&record_id, session)
7496 .await;
7497 });
7498 }
7499 }
7500
7501 async fn reconcile_context_pack_export_for_record(
7520 &self,
7521 record_id: &SessionRecordId,
7522 session: SessionAddress,
7523 ) {
7524 let Ok(record) = self.record(record_id) else {
7525 return;
7526 };
7527 if !matches!(
7528 record.state,
7529 ManagedSessionState::Live | ManagedSessionState::Dormant
7530 ) || record.exported_context.is_some()
7531 || record.provider_session.is_none()
7532 {
7533 return;
7534 }
7535 let Ok(receipt) = self
7542 .export_context_pack_for_session_record(&record.record_id, &session, true)
7543 .await
7544 else {
7545 return;
7546 };
7547 self.set_exported_context(&record.record_id, receipt);
7548 }
7549
7550 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7555 let transaction = self
7556 .state_transaction
7557 .lock()
7558 .unwrap_or_else(|poisoned| poisoned.into_inner());
7559 let (previous, updated) = {
7560 let mut records = self
7561 .session_records
7562 .lock()
7563 .unwrap_or_else(|poisoned| poisoned.into_inner());
7564 let Some(record) = records.records.get_mut(record_id) else {
7565 return;
7566 };
7567 if record.exported_context.is_some() {
7568 return;
7569 }
7570 let previous = record.clone();
7571 record.exported_context = Some(receipt);
7572 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7573 (previous, record.clone())
7574 };
7575 if self.persist_state_locked().is_err() {
7576 self.session_records
7577 .lock()
7578 .unwrap_or_else(|poisoned| poisoned.into_inner())
7579 .records
7580 .insert(record_id.clone(), previous);
7581 return;
7582 }
7583 drop(transaction);
7584 self.publish_record(updated);
7585 }
7586
7587 fn managed_record_export_target_for_instance(
7597 &self,
7598 instance_id: AgentInstanceId,
7599 generation: SessionGeneration,
7600 ) -> Option<(SessionRecordId, SessionAddress)> {
7601 let bindings = self
7602 .session_bindings
7603 .lock()
7604 .unwrap_or_else(|poisoned| poisoned.into_inner());
7605 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7606 let record_id = binding.record_id.clone()?;
7607 Some((
7608 record_id,
7609 SessionAddress {
7610 workspace_id: binding.workspace_id.clone(),
7611 session: SessionKey { instance_id, generation },
7612 },
7613 ))
7614 }
7615
7616 fn mark_managed_recovery_required(
7617 &self,
7618 lease_id: &ManagedWorktreeLeaseId,
7619 failure_kind: ManagedWorktreeCleanupFailure,
7620 ) {
7621 let snapshot = {
7622 let mut registry = self.managed_worktrees.lock()
7623 .unwrap_or_else(|poisoned| poisoned.into_inner());
7624 let Some(lease) = registry.get_mut(lease_id) else { return };
7625 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7626 lease.cleanup_failure = Some(failure_kind);
7627 lease.updated_at_unix_ms = unix_time_ms();
7628 lease.snapshot()
7629 };
7630 let _ = self.persist_state();
7631 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7632 }
7633
7634 fn set_persistence_error(&self, error: Option<String>) {
7635 *self
7636 .persistence_error
7637 .write()
7638 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7639 .map(|message| session_registry::sanitized_persistence_summary(&message));
7640 }
7641
7642 fn persist_state(&self) -> io::Result<()> {
7643 let _transaction = self
7644 .state_transaction
7645 .lock()
7646 .unwrap_or_else(|poisoned| poisoned.into_inner());
7647 self.persist_state_locked()
7648 }
7649
7650 fn persist_state_locked(&self) -> io::Result<()> {
7651 let workspaces = self
7652 .workspaces
7653 .read()
7654 .unwrap_or_else(|poisoned| poisoned.into_inner())
7655 .clone();
7656 let records = self
7657 .session_records
7658 .lock()
7659 .unwrap_or_else(|poisoned| poisoned.into_inner())
7660 .records
7661 .values()
7662 .cloned()
7663 .collect::<Vec<_>>();
7664 let mut managed = self.managed_worktrees
7665 .lock()
7666 .unwrap_or_else(|poisoned| poisoned.into_inner());
7667 managed.reattach_record_holders(
7668 &managed_worktree_record_holders(&records),
7669 unix_time_ms(),
7670 );
7671 let materializations = self
7672 .materializations
7673 .lock()
7674 .unwrap_or_else(|poisoned| poisoned.into_inner())
7675 .values()
7676 .cloned()
7677 .collect::<Vec<_>>();
7678 let managed_spawn_replays = self.managed_spawn_replays
7679 .lock()
7680 .unwrap_or_else(|poisoned| poisoned.into_inner())
7681 .values()
7682 .cloned()
7683 .collect::<Vec<_>>();
7684 let result = session_registry::save_v10(
7685 self.state_path.as_deref(),
7686 &self.node_id,
7687 &workspaces,
7688 &records,
7689 &managed.records(),
7690 &managed.tombstones(),
7691 &materializations,
7692 &managed_spawn_replays,
7693 );
7694 match &result {
7695 Ok(warning) => self.set_persistence_error(warning.clone()),
7696 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7697 }
7698 result.map(|_| ())
7699 }
7700
7701 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7702 let _mutation_guard = self.mutation_gate.lock().await;
7703 self.begin_shutdown_locked().await
7704 }
7705
7706 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7707 if self.shutdown.swap(true, Ordering::AcqRel) {
7708 return Ok(());
7709 }
7710 SessionLifecycleHook::on_shutdown(self);
7711 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7712 let mut first_error = None;
7713 for session in &self.handle.snapshot().sessions {
7714 let remaining = deadline.saturating_duration_since(Instant::now());
7715 if remaining.is_zero() {
7716 first_error.get_or_insert_with(|| {
7717 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7718 });
7719 break;
7720 }
7721 if let Err(error) = self
7722 .dispatch_bounded(
7723 ControlCommand::Stop {
7724 instance_id: session.instance_id,
7725 force: true,
7726 },
7727 remaining,
7728 )
7729 .await
7730 {
7731 first_error.get_or_insert(error);
7732 }
7733 }
7734 self.shutdown_notify.notify_waiters();
7735 match first_error {
7736 Some(error) => Err(error),
7737 None => Ok(()),
7738 }
7739 }
7740
7741 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7742 let bindings = self
7743 .session_bindings
7744 .lock()
7745 .unwrap_or_else(|poisoned| poisoned.into_inner());
7746 if bindings.len() >= CONTROL_SESSIONS_MAX {
7747 Err(failure(
7748 NodeFailureCode::BackendBusy,
7749 "node session-binding capacity is full; remove an existing session first",
7750 ))
7751 } else {
7752 Ok(())
7753 }
7754 }
7755
7756 fn ensure_workspace_environment_profile_compatible(
7763 &self,
7764 workspace_id: &WorkspaceId,
7765 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7766 ) -> Result<(), NodeFailure> {
7767 let bindings = self
7768 .session_bindings
7769 .lock()
7770 .unwrap_or_else(|poisoned| poisoned.into_inner());
7771 for binding in bindings.values() {
7772 if &binding.workspace_id != workspace_id {
7773 continue;
7774 }
7775 if binding.environment_profile.as_ref() != environment_profile {
7776 return Err(failure(
7777 NodeFailureCode::WorkspaceBusy,
7778 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7779 ));
7780 }
7781 }
7782 Ok(())
7783 }
7784
7785 #[cfg(test)]
7786 fn bind_session(
7787 &self,
7788 instance_id: AgentInstanceId,
7789 workspace_id: WorkspaceId,
7790 generation: SessionGeneration,
7791 ) {
7792 self.bind_session_with_policy(
7793 &SessionAddress {
7794 workspace_id,
7795 session: SessionKey { instance_id, generation },
7796 },
7797 ProviderRuntimePolicy::raw_pty(),
7798 None,
7799 );
7800 }
7801
7802 #[cfg(test)]
7803 fn bind_session_with_policy(
7804 &self,
7805 address: &SessionAddress,
7806 runtime_policy: ProviderRuntimePolicy,
7807 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7808 ) {
7809 self.bind_session_with_materialization(
7810 address,
7811 runtime_policy,
7812 environment_profile,
7813 None,
7814 None,
7815 None,
7816 );
7817 }
7818
7819 fn bind_session_with_materialization(
7820 &self,
7821 address: &SessionAddress,
7822 runtime_policy: ProviderRuntimePolicy,
7823 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7824 bundle: Option<ResolvedBundleReceipt>,
7825 context: Option<ResolvedContextPackReceipt>,
7826 materialization_id: Option<MaterializationId>,
7827 ) {
7828 let mut bindings = self
7829 .session_bindings
7830 .lock()
7831 .unwrap_or_else(|poisoned| poisoned.into_inner());
7832 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7833 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7834 bindings.insert(address.session.instance_id, SessionBinding {
7835 workspace_id: address.workspace_id.clone(),
7836 generation: address.session.generation,
7837 runtime_policy,
7838 pending_resume: None,
7839 record_id: None,
7840 managed_worktree_lease_id: None,
7841 environment_profile,
7842 bundle,
7843 context,
7844 materialization_id,
7845 });
7846 }
7847
7848 #[cfg(test)]
7849 fn bind_managed_session(
7850 &self,
7851 address: &SessionAddress,
7852 record_id: SessionRecordId,
7853 runtime_policy: ProviderRuntimePolicy,
7854 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7855 ) {
7856 self.bind_managed_session_with_materialization(
7857 address,
7858 record_id,
7859 runtime_policy,
7860 environment_profile,
7861 None,
7862 None,
7863 None,
7864 );
7865 }
7866
7867 fn bind_managed_session_with_materialization(
7868 &self,
7869 address: &SessionAddress,
7870 record_id: SessionRecordId,
7871 runtime_policy: ProviderRuntimePolicy,
7872 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7873 bundle: Option<ResolvedBundleReceipt>,
7874 context: Option<ResolvedContextPackReceipt>,
7875 materialization_id: Option<MaterializationId>,
7876 ) {
7877 let mut bindings = self
7878 .session_bindings
7879 .lock()
7880 .unwrap_or_else(|poisoned| poisoned.into_inner());
7881 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7882 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7883 bindings.insert(
7884 address.session.instance_id,
7885 SessionBinding {
7886 workspace_id: address.workspace_id.clone(),
7887 generation: address.session.generation,
7888 runtime_policy,
7889 pending_resume: None,
7890 record_id: Some(record_id),
7891 managed_worktree_lease_id: None,
7892 environment_profile,
7893 bundle,
7894 context,
7895 materialization_id,
7896 },
7897 );
7898 }
7899
7900 #[cfg(test)]
7901 fn bind_spawn_session(
7902 &self,
7903 address: &SessionAddress,
7904 provider: AgentId,
7905 mode: SessionMode,
7906 runtime_policy: ProviderRuntimePolicy,
7907 record_policy: SpawnRecordPolicy,
7908 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7909 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7910 self.bind_spawn_session_with_materialization(
7911 address,
7912 provider,
7913 mode,
7914 runtime_policy,
7915 record_policy,
7916 environment_profile,
7917 None,
7918 None,
7919 None,
7920 )
7921 }
7922
7923 fn bind_spawn_session_with_materialization(
7924 &self,
7925 address: &SessionAddress,
7926 provider: AgentId,
7927 mode: SessionMode,
7928 runtime_policy: ProviderRuntimePolicy,
7929 record_policy: SpawnRecordPolicy,
7930 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7931 bundle: Option<ResolvedBundleReceipt>,
7932 context: Option<ResolvedContextPackReceipt>,
7933 materialization_id: Option<MaterializationId>,
7934 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7935 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7936 && !runtime_policy.provider_session_identity
7937 {
7938 self.bind_session_with_materialization(
7939 address,
7940 runtime_policy,
7941 environment_profile,
7942 bundle,
7943 context,
7944 materialization_id,
7945 );
7946 return Ok(None);
7947 }
7948 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7949 let record = self.new_record(
7950 address,
7951 provider,
7952 mode,
7953 active_record_state(runtime_policy.provider_session_identity, false),
7954 None,
7955 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7956 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7957 record_owns_lifecycle.then(|| context.clone()).flatten(),
7958 )?;
7959 let record_id = record.record_id.clone();
7960 let transaction = self
7961 .state_transaction
7962 .lock()
7963 .unwrap_or_else(|poisoned| poisoned.into_inner());
7964 self.insert_record(record.clone())?;
7965 let previous_materialization = if record_owns_lifecycle {
7966 if let Some(materialization_id) = materialization_id.as_ref() {
7967 let mut materializations = self
7968 .materializations
7969 .lock()
7970 .unwrap_or_else(|poisoned| poisoned.into_inner());
7971 let Some(ownership) = materializations.get_mut(materialization_id) else {
7972 drop(materializations);
7973 self.remove_record_memory(&record_id);
7974 return Err(failure(
7975 NodeFailureCode::BackendOperationFailed,
7976 "session environment ownership disappeared before binding",
7977 ));
7978 };
7979 let previous = ownership.clone();
7980 if ownership
7981 .transfer_to_record(record_id.clone(), unix_time_ms())
7982 .is_err()
7983 {
7984 drop(materializations);
7985 self.remove_record_memory(&record_id);
7986 return Err(failure(
7987 NodeFailureCode::BackendOperationFailed,
7988 "session environment ownership transfer failed",
7989 ));
7990 }
7991 Some(previous)
7992 } else {
7993 None
7994 }
7995 } else {
7996 None
7997 };
7998 self.bind_managed_session_with_materialization(
7999 address,
8000 record_id.clone(),
8001 runtime_policy,
8002 environment_profile,
8003 bundle,
8004 context,
8005 materialization_id.clone(),
8006 );
8007 if let Err(error) = self.persist_state_locked() {
8008 self.remove_binding(address);
8009 self.remove_record_memory(&record_id);
8010 if let (Some(materialization_id), Some(previous)) =
8011 (materialization_id.as_ref(), previous_materialization)
8012 {
8013 self.materializations
8014 .lock()
8015 .unwrap_or_else(|poisoned| poisoned.into_inner())
8016 .insert(materialization_id.clone(), previous);
8017 }
8018 return Err(persistence_failure(error));
8019 }
8020 drop(transaction);
8021 self.publish_record(record);
8022 Ok(Some(record_id))
8023 }
8024
8025 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8026 for _ in 0..8 {
8027 let bytes = random_nonce().map_err(|error| {
8028 failure(
8029 NodeFailureCode::BackendOperationFailed,
8030 &format!("session record identity generation failed: {error}"),
8031 )
8032 })?;
8033 let mut value = String::with_capacity(27);
8034 value.push_str("sr-");
8035 for byte in &bytes[..12] {
8036 use std::fmt::Write as _;
8037 let _ = write!(&mut value, "{byte:02x}");
8038 }
8039 let record_id = SessionRecordId::new(value).map_err(|error| {
8040 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8041 })?;
8042 let records = self
8043 .session_records
8044 .lock()
8045 .unwrap_or_else(|poisoned| poisoned.into_inner());
8046 if !records.records.contains_key(&record_id) {
8047 return Ok(record_id);
8048 }
8049 }
8050 Err(failure(
8051 NodeFailureCode::BackendBusy,
8052 "could not allocate a unique session record ID",
8053 ))
8054 }
8055
8056 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8057 for _ in 0..8 {
8058 let bytes = random_nonce().map_err(|error| {
8059 failure(
8060 NodeFailureCode::BackendOperationFailed,
8061 &format!("task identity generation failed: {error}"),
8062 )
8063 })?;
8064 let mut nonce = [0_u8; 12];
8065 nonce.copy_from_slice(&bytes[..12]);
8066 let task_id = TaskId::from_nonce(nonce);
8067 let records = self
8068 .session_records
8069 .lock()
8070 .unwrap_or_else(|poisoned| poisoned.into_inner());
8071 if !records.records.values().any(|record| {
8072 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8073 == Some(&task_id)
8074 }) {
8075 return Ok(task_id);
8076 }
8077 }
8078 Err(failure(
8079 NodeFailureCode::BackendBusy,
8080 "could not allocate a unique task ID",
8081 ))
8082 }
8083
8084 fn default_record_name(&self, provider: &AgentId) -> String {
8085 let records = self
8086 .session_records
8087 .lock()
8088 .unwrap_or_else(|poisoned| poisoned.into_inner());
8089 let ordinal = records
8090 .records
8091 .values()
8092 .filter(|record| &record.provider == provider)
8093 .count()
8094 .saturating_add(1);
8095 format!("{} #{ordinal}", provider.as_str())
8096 }
8097
8098 fn new_record(
8099 &self,
8100 address: &SessionAddress,
8101 provider: AgentId,
8102 mode: SessionMode,
8103 state: ManagedSessionState,
8104 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8105 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8106 bundle: Option<ResolvedBundleReceipt>,
8107 context: Option<ResolvedContextPackReceipt>,
8108 ) -> Result<ManagedSessionRecord, NodeFailure> {
8109 let canonical_root = self.workspace_root(&address.workspace_id)?;
8110 let now = unix_time_ms();
8111 Ok(ManagedSessionRecord {
8112 record_id: self.allocate_record_id()?,
8113 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8114 provider,
8115 mode,
8116 state,
8117 workspace_id: address.workspace_id.clone(),
8118 canonical_root: opaque_windows_path(canonical_root),
8119 provider_session,
8120 active_session: Some(address.clone()),
8121 environment_profile,
8122 bundle,
8123 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8124 context,
8125 exported_context: None,
8126 task_binding: None,
8127 created_at_unix_ms: now,
8128 updated_at_unix_ms: now,
8129 last_error: None,
8130 })
8131 }
8132
8133 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8134 let mut records = self
8135 .session_records
8136 .lock()
8137 .unwrap_or_else(|poisoned| poisoned.into_inner());
8138 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8139 return Err(failure(
8140 NodeFailureCode::BackendBusy,
8141 "managed session record capacity is full; forget an inactive record first",
8142 ));
8143 }
8144 if let Some(identity) = record.provider_session.as_ref() {
8145 if records.records.values().any(|candidate| {
8146 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8147 session_registry::same_provider_session(
8148 &candidate.provider,
8149 candidate_identity,
8150 &record.provider,
8151 identity,
8152 )
8153 })
8154 }) {
8155 return Err(failure(
8156 NodeFailureCode::SessionRecordConflict,
8157 "provider session is already represented by another managed record",
8158 ));
8159 }
8160 }
8161 match records.records.entry(record.record_id.clone()) {
8162 std::collections::btree_map::Entry::Vacant(entry) => {
8163 entry.insert(record);
8164 }
8165 std::collections::btree_map::Entry::Occupied(_) => {
8166 return Err(failure(
8167 NodeFailureCode::SessionRecordConflict,
8168 "session record ID collision",
8169 ));
8170 }
8171 }
8172 Ok(())
8173 }
8174
8175 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8176 self.session_records
8177 .lock()
8178 .unwrap_or_else(|poisoned| poisoned.into_inner())
8179 .records
8180 .get(record_id)
8181 .cloned()
8182 .ok_or_else(|| {
8183 failure(
8184 NodeFailureCode::UnknownSessionRecord,
8185 "managed session record does not exist",
8186 )
8187 })
8188 }
8189
8190 fn revalidate_session_record_preview(
8191 &self,
8192 expected: &ManagedSessionRecord,
8193 identity: &ProviderSessionIdentity,
8194 ) -> Result<(), NodeFailure> {
8195 let current = self.record(&expected.record_id)?;
8196 let current_root = self.workspace_root(¤t.workspace_id)?;
8197 if current.provider != expected.provider
8198 || current.workspace_id != expected.workspace_id
8199 || current.canonical_root != expected.canonical_root
8200 || current.provider_session.as_ref() != Some(identity)
8201 || !platform::roots_equal(
8202 ¤t_root,
8203 windows_path_text(¤t.canonical_root),
8204 )
8205 {
8206 return Err(failure(
8207 NodeFailureCode::SessionRecordConflict,
8208 "managed session changed while its preview was loading",
8209 ));
8210 }
8211 Ok(())
8212 }
8213
8214 fn revalidate_session_record_context_export(
8215 &self,
8216 expected: &ManagedSessionRecord,
8217 identity: Option<&ProviderSessionIdentity>,
8218 session: &SessionAddress,
8219 allow_clean_detachment: bool,
8220 ) -> Result<(), NodeFailure> {
8221 let current = self.record(&expected.record_id)?;
8222 let current_root = self.workspace_root(¤t.workspace_id)?;
8223 let snapshot = self.internal_session_snapshot(session)?;
8224 let binding = self
8225 .session_bindings
8226 .lock()
8227 .unwrap_or_else(|poisoned| poisoned.into_inner())
8228 .get(&session.session.instance_id)
8229 .cloned()
8230 .ok_or_else(|| {
8231 failure(
8232 NodeFailureCode::SessionRecordConflict,
8233 "managed session runtime binding disappeared during context export",
8234 )
8235 })?;
8236 if !session_record_context_export_binding_is_exact(
8237 expected,
8238 ¤t,
8239 identity,
8240 session,
8241 &binding,
8242 &snapshot.agent_id,
8243 ¤t_root,
8244 allow_clean_detachment,
8245 ) || !session_record_context_export_source_is_usable(
8246 ¤t,
8247 &snapshot.status,
8248 allow_clean_detachment,
8249 ) {
8250 return Err(failure(
8251 NodeFailureCode::SessionRecordConflict,
8252 "managed session binding changed during context export",
8253 ));
8254 }
8255 Ok(())
8256 }
8257
8258 fn revalidate_loaded_history(
8259 &self,
8260 session: &SessionAddress,
8261 candidate_id: &str,
8262 loaded: &HistorySessionRecord,
8263 ) -> Result<(), NodeFailure> {
8264 let snapshot = self.internal_session_snapshot(session)?;
8265 if snapshot.history.pending.is_some()
8266 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8267 || snapshot.history.loaded.as_ref() != Some(loaded)
8268 {
8269 return Err(failure(
8270 NodeFailureCode::SessionRecordConflict,
8271 "managed session history changed during context export",
8272 ));
8273 }
8274 Ok(())
8275 }
8276
8277 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8278 self.session_records
8279 .lock()
8280 .unwrap_or_else(|poisoned| poisoned.into_inner())
8281 .records
8282 .remove(record_id)
8283 }
8284
8285 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8286 if self.destroy_record_materialization(record_id)? {
8287 self.publish(NodeEvent::SessionRecordRemoved {
8288 record_id: record_id.clone(),
8289 });
8290 return Ok(());
8291 }
8292 let transaction = self
8293 .state_transaction
8294 .lock()
8295 .unwrap_or_else(|poisoned| poisoned.into_inner());
8296 let Some(removed) = self.remove_record_memory(record_id) else {
8297 return Ok(());
8298 };
8299 if let Err(error) = self.persist_state_locked() {
8300 let _ = self.insert_record(removed);
8301 return Err(persistence_failure(error));
8302 }
8303 drop(transaction);
8304 self.publish(NodeEvent::SessionRecordRemoved {
8305 record_id: record_id.clone(),
8306 });
8307 Ok(())
8308 }
8309
8310 fn publish_record(&self, record: ManagedSessionRecord) {
8311 self.publish(NodeEvent::SessionRecordUpserted { record });
8312 }
8313
8314 fn mark_record_error(
8315 &self,
8316 record_id: &SessionRecordId,
8317 message: &str,
8318 ) -> Result<(), NodeFailure> {
8319 let transaction = self
8320 .state_transaction
8321 .lock()
8322 .unwrap_or_else(|poisoned| poisoned.into_inner());
8323 let previous = {
8324 let mut records = self
8325 .session_records
8326 .lock()
8327 .unwrap_or_else(|poisoned| poisoned.into_inner());
8328 let Some(record) = records.records.get_mut(record_id) else {
8329 return Ok(());
8330 };
8331 let previous = record.clone();
8332 record.state = ManagedSessionState::Unavailable;
8333 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8334 record.updated_at_unix_ms = unix_time_ms();
8335 previous
8336 };
8337 if let Err(error) = self.persist_state_locked() {
8338 self.session_records
8339 .lock()
8340 .unwrap_or_else(|poisoned| poisoned.into_inner())
8341 .records
8342 .insert(record_id.clone(), previous);
8343 return Err(persistence_failure(error));
8344 }
8345 let record = self.record(record_id)?;
8346 drop(transaction);
8347 self.publish_record(record);
8348 Ok(())
8349 }
8350
8351 fn rename_session_record(
8352 &self,
8353 record_id: &SessionRecordId,
8354 display_name: String,
8355 ) -> Result<ManagedSessionRecord, NodeFailure> {
8356 validate_display_name(&display_name)
8357 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8358 let _transaction = self
8359 .state_transaction
8360 .lock()
8361 .unwrap_or_else(|poisoned| poisoned.into_inner());
8362 let previous = {
8363 let mut records = self
8364 .session_records
8365 .lock()
8366 .unwrap_or_else(|poisoned| poisoned.into_inner());
8367 let record = records.records.get_mut(record_id).ok_or_else(|| {
8368 failure(
8369 NodeFailureCode::UnknownSessionRecord,
8370 "managed session record does not exist",
8371 )
8372 })?;
8373 let previous = record.clone();
8374 record.display_name = display_name;
8375 record.updated_at_unix_ms = unix_time_ms();
8376 previous
8377 };
8378 if let Err(error) = self.persist_state_locked() {
8379 self.session_records
8380 .lock()
8381 .unwrap_or_else(|poisoned| poisoned.into_inner())
8382 .records
8383 .insert(record_id.clone(), previous);
8384 return Err(persistence_failure(error));
8385 }
8386 let updated = self.record(record_id)?;
8387 self.publish_record(updated.clone());
8388 Ok(updated)
8389 }
8390
8391 fn set_session_task(
8392 &self,
8393 record_id: &SessionRecordId,
8394 expected_revision: u64,
8395 target: SessionTaskTargetV1,
8396 ) -> Result<ManagedSessionRecord, NodeFailure> {
8397 let transaction = self
8398 .state_transaction
8399 .lock()
8400 .unwrap_or_else(|poisoned| poisoned.into_inner());
8401 let current = self.record(record_id)?;
8402 let current_task_id = current
8403 .task_binding
8404 .as_ref()
8405 .and_then(|binding| binding.task_id.as_ref());
8406 let current_revision = current
8407 .task_binding
8408 .as_ref()
8409 .map_or(0, |binding| binding.revision);
8410 if expected_revision != current_revision {
8411 return Err(failure(
8412 NodeFailureCode::SessionRecordConflict,
8413 "managed session task binding revision changed",
8414 ));
8415 }
8416 let exact = match &target {
8417 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8418 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8419 SessionTaskTargetV1::New => false,
8420 };
8421 if exact {
8422 return Ok(current);
8423 }
8424 let revision = current_revision.checked_add(1).ok_or_else(|| {
8425 failure(
8426 NodeFailureCode::SessionRecordConflict,
8427 "managed session task binding revision is exhausted",
8428 )
8429 })?;
8430 let task_id = match target {
8431 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8432 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8433 SessionTaskTargetV1::Clear => None,
8434 };
8435 let changed_at_unix_ms = unix_time_ms()
8436 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8437 failure(
8438 NodeFailureCode::SessionRecordConflict,
8439 "managed session record timestamp is exhausted",
8440 )
8441 })?);
8442 let updated = {
8443 let mut records = self
8444 .session_records
8445 .lock()
8446 .unwrap_or_else(|poisoned| poisoned.into_inner());
8447 let record = records.records.get_mut(record_id).ok_or_else(|| {
8448 failure(
8449 NodeFailureCode::UnknownSessionRecord,
8450 "managed session record does not exist",
8451 )
8452 })?;
8453 record.task_binding = Some(SessionTaskBindingV1 {
8454 revision,
8455 task_id,
8456 changed_at_unix_ms,
8457 });
8458 record.updated_at_unix_ms = changed_at_unix_ms;
8459 record.clone()
8460 };
8461 if let Err(error) = self.persist_state_locked() {
8462 self.session_records
8463 .lock()
8464 .unwrap_or_else(|poisoned| poisoned.into_inner())
8465 .records
8466 .insert(record_id.clone(), current);
8467 return Err(persistence_failure(error));
8468 }
8469 drop(transaction);
8470 self.publish_record(updated.clone());
8471 Ok(updated)
8472 }
8473
8474 fn index_provider_session(
8475 &self,
8476 workspace_id: WorkspaceId,
8477 provider: AgentId,
8478 identity: ProviderSessionIdentity,
8479 display_name: String,
8480 ) -> Result<ManagedSessionRecord, NodeFailure> {
8481 self.index_provider_session_with_policy(
8482 workspace_id,
8483 provider,
8484 identity,
8485 display_name,
8486 false,
8487 )
8488 }
8489
8490 fn index_provider_session_with_policy(
8491 &self,
8492 workspace_id: WorkspaceId,
8493 provider: AgentId,
8494 identity: ProviderSessionIdentity,
8495 display_name: String,
8496 allow_validated_transcript_path: bool,
8497 ) -> Result<ManagedSessionRecord, NodeFailure> {
8498 validate_display_name(&display_name)
8499 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8500 identity
8501 .validate()
8502 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8503 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8504 return Err(failure(
8505 NodeFailureCode::InvalidRequest,
8506 "provider session reference index v1 does not accept transcript paths",
8507 ));
8508 }
8509 let canonical_root = self.workspace_root(&workspace_id)?;
8510 if !self.enabled_providers.contains(&provider) {
8511 return Err(failure(
8512 NodeFailureCode::InvalidRequest,
8513 "provider is not enabled on this node",
8514 ));
8515 }
8516
8517 let transaction = self
8518 .state_transaction
8519 .lock()
8520 .unwrap_or_else(|poisoned| poisoned.into_inner());
8521 {
8522 let records = self
8523 .session_records
8524 .lock()
8525 .unwrap_or_else(|poisoned| poisoned.into_inner());
8526 if let Some(existing) = records.records.values().find(|candidate| {
8527 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8528 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8529 })
8530 }) {
8531 if existing.provider == provider && existing.workspace_id == workspace_id {
8532 return Ok(existing.clone());
8533 }
8534 return Err(failure(
8535 NodeFailureCode::SessionRecordConflict,
8536 "provider session reference conflicts with another provider or workspace",
8537 ));
8538 }
8539 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8540 return Err(failure(
8541 NodeFailureCode::BackendBusy,
8542 "managed session record capacity is full; forget an inactive record first",
8543 ));
8544 }
8545 }
8546
8547 let now = unix_time_ms();
8548 let record = ManagedSessionRecord {
8549 record_id: self.allocate_record_id()?,
8550 display_name,
8551 provider,
8552 mode: SessionMode::Pty,
8553 state: ManagedSessionState::Dormant,
8554 workspace_id,
8555 canonical_root: opaque_windows_path(canonical_root),
8556 provider_session: Some(identity),
8557 active_session: None,
8558 environment_profile: None,
8559 bundle: None,
8560 context_id: None,
8561 context: None,
8562 exported_context: None,
8563 task_binding: None,
8564 created_at_unix_ms: now,
8565 updated_at_unix_ms: now,
8566 last_error: None,
8567 };
8568 self.insert_record(record.clone())?;
8569 if let Err(error) = self.persist_state_locked() {
8570 self.remove_record_memory(&record.record_id);
8571 return Err(persistence_failure(error));
8572 }
8573 drop(transaction);
8574 self.publish_record(record.clone());
8575 Ok(record)
8576 }
8577
8578 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8579 self.session_bindings
8580 .lock()
8581 .unwrap_or_else(|poisoned| poisoned.into_inner())
8582 .iter()
8583 .find_map(|(instance_id, binding)| {
8584 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8585 workspace_id: binding.workspace_id.clone(),
8586 session: SessionKey {
8587 instance_id: *instance_id,
8588 generation: binding.generation,
8589 },
8590 })
8591 })
8592 }
8593
8594 async fn forget_session_record(
8595 &self,
8596 record_id: &SessionRecordId,
8597 ) -> Result<(), NodeFailure> {
8598 let record = self.record(record_id)?;
8599 if record.active_session.is_some()
8600 || matches!(
8601 record.state,
8602 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8603 )
8604 {
8605 return Err(failure(
8606 NodeFailureCode::SessionRecordBusy,
8607 "stop the managed session before forgetting it",
8608 ));
8609 }
8610 if let Some(address) = self.bound_address_for_record(record_id) {
8611 self.remove_session(&address).await?;
8612 }
8613 self.remove_managed_session_record(record_id, &record.workspace_id).await
8614 }
8615
8616 async fn remove_managed_session_record(
8631 &self,
8632 record_id: &SessionRecordId,
8633 workspace_id: &WorkspaceId,
8634 ) -> Result<(), NodeFailure> {
8635 let managed_lease_id = self.managed_worktrees.lock()
8636 .unwrap_or_else(|poisoned| poisoned.into_inner())
8637 .lease_for_workspace(workspace_id);
8638 if !self.destroy_record_materialization(record_id)? {
8639 let transaction = self
8640 .state_transaction
8641 .lock()
8642 .unwrap_or_else(|poisoned| poisoned.into_inner());
8643 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8644 failure(
8645 NodeFailureCode::UnknownSessionRecord,
8646 "managed session record does not exist",
8647 )
8648 })?;
8649 if let Err(error) = self.persist_state_locked() {
8650 let _ = self.insert_record(removed);
8651 return Err(persistence_failure(error));
8652 }
8653 drop(transaction);
8654 }
8655 self.publish(NodeEvent::SessionRecordRemoved {
8656 record_id: record_id.clone(),
8657 });
8658 if let Some(lease_id) = managed_lease_id {
8659 if let Some(snapshot) = self.managed_worktrees.lock()
8660 .unwrap_or_else(|poisoned| poisoned.into_inner())
8661 .get(&lease_id)
8662 .map(ManagedWorktreeLeaseRecord::snapshot)
8663 {
8664 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8665 }
8666 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8667 }
8668 Ok(())
8669 }
8670
8671 async fn retire_unavailable_session_records(&self) {
8684 let policy = self.session_record_retention;
8685 let age_enabled = policy.age_ms > 0;
8686 let keep_enabled = policy.keep_per_workspace > 0;
8687 if !age_enabled && !keep_enabled {
8688 return;
8689 }
8690 let now = unix_time_ms();
8691 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8692 self.session_records
8693 .lock()
8694 .unwrap_or_else(|poisoned| poisoned.into_inner())
8695 .records
8696 .values()
8697 .filter(|record| record.state == ManagedSessionState::Unavailable)
8698 .map(|record| {
8699 (
8700 record.record_id.clone(),
8701 record.workspace_id.clone(),
8702 record.provider.clone(),
8703 record.updated_at_unix_ms,
8704 )
8705 })
8706 .collect()
8707 };
8708 if candidates.is_empty() {
8709 return;
8710 }
8711 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8712 BTreeMap::new();
8713 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8714 by_workspace
8715 .entry(workspace_id)
8716 .or_default()
8717 .push((record_id, provider, updated_at_unix_ms));
8718 }
8719 let reason = if age_enabled && keep_enabled {
8720 "age+keep-n"
8721 } else if age_enabled {
8722 "age"
8723 } else {
8724 "keep-n"
8725 };
8726 let mut retired: u64 = 0;
8727 for (workspace_id, mut records) in by_workspace {
8728 records.sort_by(|left, right| right.2.cmp(&left.2));
8731 for (index, (record_id, provider, updated_at_unix_ms)) in
8732 records.into_iter().enumerate()
8733 {
8734 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8735 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8736 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8737 let should_retire = if age_enabled && keep_enabled {
8738 age_hit && keep_hit
8739 } else {
8740 age_hit || keep_hit
8741 };
8742 if !should_retire {
8743 continue;
8744 }
8745 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8746 Ok(()) => {
8747 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8748 retired += 1;
8749 tracing::info!(
8750 node_id = %self.node_id,
8751 record_id = %record_id,
8752 workspace_id = %workspace_id,
8753 provider = %provider,
8754 age_ms = record_age_ms,
8755 reason,
8756 "managed session record retired"
8757 );
8758 }
8759 Err(error) => tracing::warn!(
8760 node_id = %self.node_id,
8761 record_id = %record_id,
8762 workspace_id = %workspace_id,
8763 code = ?error.code,
8764 cause = %error.message,
8765 "session record retention sweep failed to retire a record"
8766 ),
8767 }
8768 }
8769 }
8770 if retired > 0 {
8771 let remaining_unavailable = self
8772 .session_records
8773 .lock()
8774 .unwrap_or_else(|poisoned| poisoned.into_inner())
8775 .records
8776 .values()
8777 .filter(|record| record.state == ManagedSessionState::Unavailable)
8778 .count();
8779 tracing::info!(
8780 node_id = %self.node_id,
8781 retired,
8782 remaining_unavailable,
8783 "session record retention sweep completed"
8784 );
8785 }
8786 }
8787
8788 async fn resume_session_record(
8789 &self,
8790 record_id: &SessionRecordId,
8791 terminal_size: gate4agent_types::TerminalSize,
8792 initial_prompt: Option<String>,
8793 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8794 let mut record = self.record(record_id)?;
8795 if record.mode == SessionMode::Acp
8798 && record.provider.as_str() == "claude"
8799 && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8800 {
8801 record.mode = SessionMode::Pty;
8802 if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8803 r.mode = SessionMode::Pty;
8804 }
8805 tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8806 }
8807 if record.active_session.is_some()
8808 || matches!(
8809 record.state,
8810 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8811 )
8812 {
8813 return Err(failure(
8814 NodeFailureCode::SessionRecordBusy,
8815 "managed session already has a live runtime binding",
8816 ));
8817 }
8818 let identity = record.provider_session.clone().ok_or_else(|| {
8819 failure(
8820 NodeFailureCode::SessionRecordNotResumable,
8821 "managed session has no verified provider session identity",
8822 )
8823 })?;
8824 let working_directory = self.workspace_root(&record.workspace_id)?;
8825 if !platform::roots_equal(
8826 &working_directory,
8827 windows_path_text(&record.canonical_root),
8828 ) {
8829 return Err(failure(
8830 NodeFailureCode::SessionRecordConflict,
8831 "managed session workspace root changed; refusing to resume in another directory",
8832 ));
8833 }
8834 {
8835 let records = self
8836 .session_records
8837 .lock()
8838 .unwrap_or_else(|poisoned| poisoned.into_inner());
8839 if records.records.values().any(|candidate| {
8840 candidate.record_id != *record_id
8841 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8842 session_registry::same_provider_session(
8843 &candidate.provider,
8844 candidate_identity,
8845 &record.provider,
8846 &identity,
8847 )
8848 })
8849 && (candidate.active_session.is_some()
8850 || matches!(
8851 candidate.state,
8852 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8853 ))
8854 }) {
8855 return Err(failure(
8856 NodeFailureCode::SessionRecordConflict,
8857 "provider session is already active through another managed record",
8858 ));
8859 }
8860 }
8861 let runtime_requirement = if initial_prompt.is_some() {
8862 ProviderRuntimeRequirement::ResumeWithPrompt
8863 } else {
8864 ProviderRuntimeRequirement::Resume
8865 };
8866 let runtime_policy = self.admit_provider_runtime(
8867 &record.provider,
8868 runtime_requirement,
8869 )
8870 .await?;
8871 let working_directory = match self.m4a_accounts.as_ref() {
8873 Some(_) => {
8874 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8875 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8876 }
8877 None => working_directory,
8878 };
8879 let request = ResumeLaunchRequest {
8880 working_directory,
8881 terminal_size,
8882 initial_prompt,
8883 };
8884 request
8885 .validate()
8886 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8887 self.ensure_binding_capacity()?;
8888 let resolved_materialization = match self.resolve_record_materialization(
8889 record_id,
8890 &record.provider,
8891 record.mode,
8892 record.environment_profile.as_ref(),
8893 record.bundle.as_ref(),
8894 record.context.as_ref(),
8895 ) {
8896 Ok(materialization) => materialization,
8897 Err(error) => {
8898 let label = match error.code {
8899 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8900 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8901 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8902 _ => "environment-profile-unavailable",
8903 };
8904 self.mark_record_error(record_id, label)?;
8905 return Err(error);
8906 }
8907 };
8908 let (materialization_id, environment_overlay) = match resolved_materialization {
8909 Some((id, overlay)) => (Some(id), overlay),
8910 None => (None, None),
8911 };
8912 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8913 let environment_overlay = {
8915 let mut args: Vec<OsString> = Vec::new();
8916 if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8917 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8918 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8919 crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8920 }
8921 args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8922 let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8923 if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8924 if let Some(sel) = options.as_ref() {
8925 args.push("--model".into()); args.push(sel.model.clone().into());
8926 if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8927 args.push("--effort".into()); args.push(effort.to_owned().into());
8928 }
8929 }
8930 }
8931 if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8932 if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8933 crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8934 instance_id: instance_id.0,
8935 provider: record.provider.as_str().to_owned(),
8936 workspace_id: record.workspace_id.as_str().to_owned(),
8937 provider_session_id: identity.id.clone(),
8938 session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8939 cwd: String::new(),
8940 acp: record.mode == SessionMode::Acp,
8941 options,
8942 cols: 0,
8943 rows: 0,
8944 origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8945 parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8946 });
8947 }
8948 }
8949 overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8950 };
8951 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8953 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8954 if let Some(old) = self.bound_address_for_record(record_id) {
8955 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8956 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8957 }
8958 }
8959 let session_key = self.m4a_session_key(instance_id);
8960 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8961 }
8962 _ => environment_overlay,
8963 };
8964 let mut environment_selection = match SpawnPreparer::select_environment(self,
8965 instance_id,
8966 &record.provider,
8967 record.mode,
8968 record.environment_profile.as_ref(),
8969 ) {
8970 Ok(selection) => selection,
8971 Err(error) => {
8972 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8973 return Err(error);
8974 }
8975 };
8976 let mut instance_overlay = environment_overlay
8977 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8978 .transpose()?
8979 .flatten();
8980 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8981 self.remove_session(&stale_address).await?;
8982 }
8983 let address = SessionAddress {
8984 workspace_id: record.workspace_id.clone(),
8985 session: SessionKey {
8986 instance_id,
8987 generation: SessionGeneration(1),
8988 },
8989 };
8990 let transaction = self
8991 .state_transaction
8992 .lock()
8993 .unwrap_or_else(|poisoned| poisoned.into_inner());
8994 let current = self.record(record_id)?;
8995 if current.active_session.is_some()
8996 || matches!(
8997 current.state,
8998 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8999 )
9000 {
9001 return Err(failure(
9002 NodeFailureCode::SessionRecordBusy,
9003 "managed session became active while resume was being prepared",
9004 ));
9005 }
9006 self.bind_managed_session_with_materialization(
9007 &address,
9008 record_id.clone(),
9009 runtime_policy,
9010 record.environment_profile.clone(),
9011 record.bundle.clone(),
9012 record.context.clone(),
9013 materialization_id,
9014 );
9015 if let Some(selection) = environment_selection.take() {
9016 selection.retain();
9017 }
9018 if let Some(overlay) = instance_overlay.take() {
9019 overlay.retain();
9020 }
9021 drop(transaction);
9022
9023 let transport = match record.mode {
9024 SessionMode::Pty => TransportKind::Pty,
9025 SessionMode::Inline => TransportKind::Pipe,
9026 SessionMode::Acp => TransportKind::Acp,
9027 };
9028 let agent_id = record.provider.clone();
9029 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9030 if let Err(error) = self
9031 .dispatch_bounded(
9032 ControlCommand::Register {
9033 instance_id,
9034 agent_id,
9035 transport,
9036 },
9037 dispatch_timeout,
9038 )
9039 .await
9040 {
9041 if let Some(binding) = self.remove_binding(&address) {
9042 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9043 }
9044 return Err(error);
9045 }
9046 let command = self.prepare_command(ControlCommand::Resume {
9047 instance_id,
9048 target: ResumeTarget::ProviderSession { identity },
9049 runtime_policy,
9050 request,
9051 });
9052 let started_after = self.current_sequence();
9053 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9054 self.rollback_spawn(&address, dispatch_timeout).await?;
9055 return Err(error);
9056 }
9057 if let Err(error) = self.dispatch_envelope(command) {
9058 self.clear_armed_resume(&address);
9059 self.rollback_spawn(&address, dispatch_timeout).await?;
9060 return Err(error);
9061 }
9062 match self
9063 .wait_for_record_resume(&address, started_after)
9064 .await
9065 {
9066 Ok(settled_address) => {
9067 let managed_lease_id = {
9068 self.managed_worktrees.lock()
9069 .unwrap_or_else(|poisoned| poisoned.into_inner())
9070 .lease_for_workspace(&record.workspace_id)
9071 };
9072 if let Some(lease_id) = managed_lease_id {
9073 {
9074 let mut bindings = self.session_bindings.lock()
9075 .unwrap_or_else(|poisoned| poisoned.into_inner());
9076 let binding = bindings.get_mut(&settled_address.session.instance_id)
9077 .ok_or_else(|| failure(
9078 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9079 "resumed managed session lost its exact binding",
9080 ))?;
9081 if binding.generation != settled_address.session.generation {
9082 return Err(failure(
9083 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9084 "resumed managed session generation diverged",
9085 ));
9086 }
9087 binding.managed_worktree_lease_id = Some(lease_id.clone());
9088 }
9089 let snapshot = self.managed_worktrees.lock()
9090 .unwrap_or_else(|poisoned| poisoned.into_inner())
9091 .bind_session(
9092 &lease_id,
9093 ManagedWorktreeSessionHolder {
9094 incarnation_id: self.incarnation_id,
9095 instance_id: settled_address.session.instance_id,
9096 generation: settled_address.session.generation,
9097 },
9098 Some(record_id.clone()),
9099 unix_time_ms(),
9100 ).map_err(|message| failure(
9101 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9102 &message,
9103 ))?;
9104 if let Err(error) = self.persist_state() {
9105 let _ = self.remove_session(&settled_address).await;
9106 return Err(persistence_failure(error));
9107 }
9108 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9109 }
9110 Ok((self.record(record_id)?, settled_address))
9111 }
9112 Err(error) => {
9113 if matches!(
9114 error.code,
9115 NodeFailureCode::SessionRecordNotResumable
9116 | NodeFailureCode::BackendOperationFailed
9117 ) {
9118 if let Some(bound) = self.bound_address_for_record(record_id) {
9119 let _ = self.remove_session(&bound).await;
9120 }
9121 }
9122 Err(error)
9123 }
9124 }
9125 }
9126
9127 async fn wait_for_record_resume(
9128 &self,
9129 initial_address: &SessionAddress,
9130 after_sequence: u64,
9131 ) -> Result<SessionAddress, NodeFailure> {
9132 let deadline = Instant::now()
9133 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9134 let mut scan_after = after_sequence;
9135 loop {
9136 let events = self
9137 .history
9138 .lock()
9139 .unwrap_or_else(|poisoned| poisoned.into_inner())
9140 .events
9141 .iter()
9142 .filter(|event| event.sequence > scan_after)
9143 .cloned()
9144 .collect::<Vec<_>>();
9145 for envelope in events {
9146 scan_after = scan_after.max(envelope.sequence);
9147 let NodeEvent::Control { address, event } = envelope.event else {
9148 continue;
9149 };
9150 if address.session.instance_id != initial_address.session.instance_id {
9151 continue;
9152 }
9153 match event.event {
9154 ControlEventKind::Resumed { .. } => return Ok(address),
9155 ControlEventKind::ResumeDenied { reason } => {
9156 return Err(failure(
9157 NodeFailureCode::SessionRecordNotResumable,
9158 &reason,
9159 ));
9160 }
9161 ControlEventKind::ResumeFailed { message }
9162 | ControlEventKind::CommandRejected { message } => {
9163 return Err(failure(
9164 NodeFailureCode::BackendOperationFailed,
9165 &message,
9166 ));
9167 }
9168 _ => {}
9169 }
9170 }
9171 if Instant::now() >= deadline {
9172 return Err(failure(
9173 NodeFailureCode::BackendBusy,
9174 "managed resume did not settle before the bounded deadline",
9175 ));
9176 }
9177 sleep(Duration::from_millis(5)).await;
9178 }
9179 }
9180
9181 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9182 self.workspaces
9183 .read()
9184 .unwrap_or_else(|poisoned| poisoned.into_inner())
9185 .get(workspace_id)
9186 .cloned()
9187 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9188 }
9189
9190 fn workspace_roots(&self) -> Vec<PathBuf> {
9191 self.workspaces
9192 .read()
9193 .unwrap_or_else(|poisoned| poisoned.into_inner())
9194 .values()
9195 .map(PathBuf::from)
9196 .collect()
9197 }
9198
9199 async fn inspect_workspace(
9200 &self,
9201 workspace_id: WorkspaceId,
9202 ) -> Result<WorkspaceInspection, NodeFailure> {
9203 let _permit = self
9204 .inspection_slots
9205 .clone()
9206 .try_acquire_owned()
9207 .map_err(|_| {
9208 tracing::warn!(
9209 workspace_id = %workspace_id,
9210 "workspace inspection rejected: inspection capacity is busy",
9211 );
9212 failure(
9213 NodeFailureCode::BackendBusy,
9214 "workspace inspection capacity is busy",
9215 )
9216 })?;
9217 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9218 tracing::warn!(
9219 workspace_id = %workspace_id,
9220 code = ?error.code,
9221 "workspace inspection rejected: workspace is not registered",
9222 );
9223 error
9224 })?;
9225 let tree_root = canonical_root.clone();
9226 let started_at = Instant::now();
9227 let time_budget_ms = workspace_inspection_time_budget_ms();
9228 let entry_cap = workspace_inspection_entry_cap();
9229 let deadline = started_at + Duration::from_millis(time_budget_ms);
9230 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9231 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9232 })
9233 .await
9234 .map_err(|error| {
9235 tracing::warn!(
9236 workspace_id = %workspace_id,
9237 error = %error,
9238 "workspace inspection rejected: walk task failed",
9239 );
9240 failure(
9241 NodeFailureCode::BackendOperationFailed,
9242 &format!("workspace inspection task failed: {error}"),
9243 )
9244 })?;
9245 let (mut git, git_time_budget_exceeded) =
9246 inspect_git_workspace(&canonical_root, deadline).await;
9247 if !git.worktrees.is_empty() {
9248 let registered = self
9249 .workspaces
9250 .read()
9251 .unwrap_or_else(|poisoned| poisoned.into_inner())
9252 .iter()
9253 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9254 .collect::<Vec<_>>();
9255 for worktree in &mut git.worktrees {
9256 worktree.workspace_id = registered
9257 .iter()
9258 .find(|(_, root)| {
9259 worktree_paths_equal(root, windows_path_text(&worktree.path))
9260 })
9261 .map(|(workspace_id, _)| workspace_id.clone());
9262 }
9263 }
9264 if git.is_repository {
9265 git.managed_worktree = self
9266 .managed_worktrees
9267 .lock()
9268 .unwrap_or_else(|poisoned| poisoned.into_inner())
9269 .git_scope(&workspace_id, git.branch.as_deref());
9270 }
9271 let truncation = workspace_inspection_truncation(
9272 &walk_budget,
9273 git_time_budget_exceeded,
9274 started_at.elapsed(),
9275 );
9276 if let Some(truncation) = &truncation {
9277 tracing::info!(
9278 workspace_id = %workspace_id,
9279 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9280 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9281 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9282 entries_visited = truncation.entries_visited,
9283 elapsed_ms = truncation.elapsed_ms,
9284 "workspace inspection truncated by its inner budget",
9285 );
9286 }
9287 Ok(WorkspaceInspection {
9288 workspace_id,
9289 entries,
9290 tree_truncated,
9291 git,
9292 truncation,
9293 })
9294 }
9295
9296 async fn browse_host_directories(
9297 &self,
9298 directory: Option<OpaqueHostPath>,
9299 after: Option<OpaqueHostPath>,
9300 ) -> Result<HostDirectoryListing, NodeFailure> {
9301 let permit = self
9302 .inspection_slots
9303 .clone()
9304 .try_acquire_owned()
9305 .map_err(|_| {
9306 failure(
9307 NodeFailureCode::BackendBusy,
9308 "host directory browse capacity is busy",
9309 )
9310 })?;
9311 let task = tokio::task::spawn_blocking(move || {
9312 let _permit = permit;
9313 browse_host_directories(directory, after)
9314 });
9315 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9316 .await
9317 .map_err(|_| {
9318 failure(
9319 NodeFailureCode::HostDirectoryReadTimedOut,
9320 "host directory browse exceeded its bounded deadline",
9321 )
9322 })?
9323 .map_err(|_| {
9324 failure(
9325 NodeFailureCode::HostDirectoryReadFailed,
9326 "host directory browse task failed",
9327 )
9328 })?
9329 .map_err(host_directory_failure)
9330 }
9331
9332 async fn read_workspace_file(
9333 &self,
9334 workspace_id: WorkspaceId,
9335 path: RepositoryPath,
9336 ) -> Result<WorkspaceFileRead, NodeFailure> {
9337 let canonical_root = self.workspace_root(&workspace_id)?;
9338 let permit = self
9339 .inspection_slots
9340 .clone()
9341 .try_acquire_owned()
9342 .map_err(|_| {
9343 failure(
9344 NodeFailureCode::BackendBusy,
9345 "workspace file read capacity is busy",
9346 )
9347 })?;
9348 let path_to_read = path.clone();
9349 let task = tokio::task::spawn_blocking(move || {
9350 let _permit = permit;
9351 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9352 });
9353 let content = timeout(
9354 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9355 task,
9356 )
9357 .await
9358 .map_err(|_| {
9359 failure(
9360 NodeFailureCode::RepositoryFileReadTimedOut,
9361 "repository file read exceeded its bounded deadline",
9362 )
9363 })?
9364 .map_err(|_| {
9365 failure(
9366 NodeFailureCode::RepositoryFileReadFailed,
9367 "repository file read task failed",
9368 )
9369 })?
9370 .map_err(workspace_file_failure)?;
9371 let content = match content {
9372 WorkspaceFileBytes::Utf8(text) => {
9373 let revision = Some(workspace_file_revision(text.as_bytes()));
9374 return Ok(WorkspaceFileRead {
9375 workspace_id,
9376 path,
9377 content: WorkspaceFileContent::Utf8 {
9378 byte_len: u32::try_from(text.len())
9379 .expect("bounded workspace text length must fit u32"),
9380 text,
9381 },
9382 revision,
9383 });
9384 }
9385 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9386 WorkspaceFileContent::NonUtf8 {
9387 byte_len: u32::try_from(byte_length)
9388 .expect("bounded workspace file length must fit u32"),
9389 }
9390 }
9391 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9392 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9393 },
9394 };
9395 Ok(WorkspaceFileRead {
9396 workspace_id,
9397 path,
9398 content,
9399 revision: None,
9400 })
9401 }
9402
9403 async fn write_workspace_file(
9404 &self,
9405 workspace_id: WorkspaceId,
9406 path: RepositoryPath,
9407 expected_revision: WorkspaceFileRevision,
9408 text: String,
9409 ) -> Result<WorkspaceFileRead, NodeFailure> {
9410 let canonical_root = self.workspace_root(&workspace_id)?;
9411 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9412 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9413 })?;
9414 let path_to_write = path.clone();
9415 let expected = expected_revision.as_str().to_owned();
9416 let text_to_write = text.clone();
9417 let task = tokio::task::spawn_blocking(move || {
9418 let _permit = permit;
9419 write_workspace_file_to_disk(
9420 Path::new(&canonical_root),
9421 &path_to_write,
9422 &expected,
9423 &text_to_write,
9424 )
9425 });
9426 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9427 .await
9428 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9429 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9430 .map_err(workspace_file_write_failure)?;
9431 Ok(WorkspaceFileRead {
9432 workspace_id,
9433 path,
9434 content: WorkspaceFileContent::Utf8 {
9435 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9436 text,
9437 },
9438 revision: Some(WorkspaceFileRevision::new(revision)
9439 .expect("workspace writer returns a valid SHA-256 revision")),
9440 })
9441 }
9442
9443 async fn create_workspace_file(
9444 &self,
9445 workspace_id: WorkspaceId,
9446 path: RepositoryPath,
9447 ) -> Result<WorkspaceFileRead, NodeFailure> {
9448 let canonical_root = self.workspace_root(&workspace_id)?;
9449 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9450 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9451 })?;
9452 let path_to_create = path.clone();
9453 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9454 let task_commit_state = Arc::clone(&commit_state);
9455 let task = tokio::task::spawn_blocking(move || {
9456 let _permit = permit;
9457 create_workspace_file_on_disk(
9458 Path::new(&canonical_root),
9459 &path_to_create,
9460 &task_commit_state,
9461 )
9462 });
9463 let revision = settle_workspace_entry_create(
9464 task,
9465 commit_state,
9466 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9467 "repository file create exceeded its bounded deadline",
9468 "repository file create task failed",
9469 )
9470 .await?;
9471 Ok(WorkspaceFileRead {
9472 workspace_id,
9473 path,
9474 content: WorkspaceFileContent::Utf8 {
9475 text: String::new(),
9476 byte_len: 0,
9477 },
9478 revision: Some(WorkspaceFileRevision::new(revision)
9479 .expect("workspace creator returns a valid SHA-256 revision")),
9480 })
9481 }
9482
9483 async fn create_workspace_directory(
9484 &self,
9485 workspace_id: WorkspaceId,
9486 path: RepositoryPath,
9487 ) -> Result<WorkspaceEntry, NodeFailure> {
9488 let canonical_root = self.workspace_root(&workspace_id)?;
9489 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9490 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9491 })?;
9492 let path_to_create = path.clone();
9493 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9494 let task_commit_state = Arc::clone(&commit_state);
9495 let task = tokio::task::spawn_blocking(move || {
9496 let _permit = permit;
9497 create_workspace_directory_on_disk(
9498 Path::new(&canonical_root),
9499 &path_to_create,
9500 &task_commit_state,
9501 )
9502 });
9503 settle_workspace_entry_create(
9504 task,
9505 commit_state,
9506 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9507 "repository directory create exceeded its bounded deadline",
9508 "repository directory create task failed",
9509 )
9510 .await?;
9511 Ok(WorkspaceEntry {
9512 relative_path: path,
9513 kind: WorkspaceEntryKind::Directory,
9514 })
9515 }
9516
9517 async fn read_git_history(
9518 &self,
9519 workspace_id: WorkspaceId,
9520 path: Option<RepositoryPath>,
9521 before: Option<GitObjectId>,
9522 limit: u16,
9523 ) -> Result<GitHistoryPage, NodeFailure> {
9524 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9525 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9526 })?;
9527 let root = self.workspace_root(&workspace_id)?;
9528 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9529 }
9530
9531 async fn read_git_diff(
9532 &self,
9533 workspace_id: WorkspaceId,
9534 request: GitDiffRequest,
9535 ) -> Result<GitDiff, NodeFailure> {
9536 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9537 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9538 })?;
9539 let root = self.workspace_root(&workspace_id)?;
9540 read_git_diff_bounded(&root, request).await
9541 }
9542
9543 fn native_session_route_root(
9544 &self,
9545 route: &NativeSessionCatalogRoute,
9546 ) -> Result<Option<String>, NodeFailure> {
9547 route
9548 .validate()
9549 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9550 match route.scope {
9551 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9552 .workspace_id
9553 .as_ref()
9554 .map(|workspace_id| self.workspace_root(workspace_id))
9555 .transpose(),
9556 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9557 }
9558 }
9559
9560 fn project_native_session_entries(
9561 &self,
9562 route: &NativeSessionCatalogRoute,
9563 entries: Vec<ScopedNativeSessionCatalogEntry>,
9564 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9565 let current_root = route
9566 .workspace_id
9567 .as_ref()
9568 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9569 let records = self
9570 .session_records
9571 .lock()
9572 .unwrap_or_else(|poisoned| poisoned.into_inner());
9573 let mut seen_records = std::collections::HashSet::new();
9574 entries
9575 .into_iter()
9576 .map(|entry| {
9577 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9578 let matches = records
9579 .records
9580 .values()
9581 .filter(|record| {
9582 record.provider == route.provider
9583 && record.workspace_id == *workspace_id
9584 && current_root.as_deref().is_some_and(|root| {
9585 platform::roots_equal(
9586 root,
9587 windows_path_text(&record.canonical_root),
9588 )
9589 })
9590 && record.provider_session.as_ref().is_some_and(|identity| {
9591 session_registry::same_provider_session(
9592 &record.provider,
9593 identity,
9594 &route.provider,
9595 &entry.provider_session,
9596 )
9597 })
9598 })
9599 .map(|record| record.record_id.clone())
9600 .collect::<Vec<_>>();
9601 (matches.len() == 1
9602 && seen_records.insert(matches[0].clone()))
9603 .then(|| matches[0].clone())
9604 });
9605 let metadata = entry.metadata;
9606 crate::protocol::NativeSessionCatalogEntry {
9607 selection_id: metadata.selection_id,
9608 title: metadata.title,
9609 modified_at_unix_ms: metadata.modified_at_unix_ms,
9610 model: metadata.model,
9611 message_count: metadata.message_count,
9612 completed_turn_count: metadata.completed_turn_count,
9613 external_group: entry.external_group,
9614 record_id,
9615 }
9616 })
9617 .collect()
9618 }
9619
9620 async fn catalog_native_sessions(
9621 &self,
9622 route: NativeSessionCatalogRoute,
9623 limit: u16,
9624 ) -> Result<(
9625 Vec<crate::protocol::NativeSessionCatalogEntry>,
9626 crate::protocol::NativeSessionCatalogSummary,
9627 ), NodeFailure> {
9628 let canonical_root = self.native_session_route_root(&route)?;
9629 let registered_workspace_roots = self.workspace_roots();
9630 let provider = route.provider.clone();
9631 let scope = route.scope;
9632 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9633 failure(
9634 NodeFailureCode::InvalidRequest,
9635 "native session catalog is not configured",
9636 )
9637 })?;
9638 let _permit = Arc::clone(&self.inspection_slots)
9639 .try_acquire_owned()
9640 .map_err(|_| {
9641 failure(
9642 NodeFailureCode::BackendBusy,
9643 "native session catalog capacity is busy",
9644 )
9645 })?;
9646 let page = run_native_session_catalog_operation(
9647 catalog,
9648 "native session catalog",
9649 move |catalog| {
9650 catalog.catalog_initial_for_scope(
9651 &provider,
9652 scope,
9653 canonical_root.as_deref().map(Path::new),
9654 ®istered_workspace_roots,
9655 limit,
9656 )
9657 },
9658 )
9659 .await?;
9660 let summary = crate::protocol::NativeSessionCatalogSummary {
9661 catalog_revision: page.revision,
9662 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9663 recent_total_count: page.recent_total_count,
9664 older_total_count: page.older_total_count,
9665 recent_next_after_selection_id: page.next_after_selection_id,
9666 recent_has_more: page.remaining_count > 0,
9667 };
9668 Ok((self.project_native_session_entries(&route, page.entries), summary))
9669 }
9670
9671 async fn page_native_sessions(
9672 &self,
9673 route: NativeSessionCatalogRoute,
9674 window: crate::protocol::NativeSessionCatalogWindow,
9675 catalog_revision: u64,
9676 recent_cutoff_unix_ms: u64,
9677 after_selection_id: Option<String>,
9678 limit: u16,
9679 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9680 let canonical_root = self.native_session_route_root(&route)?;
9681 let registered_workspace_roots = self.workspace_roots();
9682 let provider = route.provider.clone();
9683 let scope = route.scope;
9684 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9685 failure(
9686 NodeFailureCode::InvalidRequest,
9687 "native session catalog is not configured",
9688 )
9689 })?;
9690 let _permit = Arc::clone(&self.inspection_slots)
9691 .try_acquire_owned()
9692 .map_err(|_| {
9693 failure(
9694 NodeFailureCode::BackendBusy,
9695 "native session catalog capacity is busy",
9696 )
9697 })?;
9698 let page = run_native_session_catalog_operation(
9699 catalog,
9700 "native session catalog page",
9701 move |catalog| {
9702 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9703 &provider,
9704 scope,
9705 canonical_root.as_deref().map(Path::new),
9706 ®istered_workspace_roots,
9707 window,
9708 catalog_revision,
9709 recent_cutoff_unix_ms,
9710 after_selection_id.as_deref(),
9711 limit,
9712 ))
9713 },
9714 )
9715 .await?;
9716 let page = page.map_err(|error| match error {
9717 NativeSessionCatalogError::StaleCatalog => failure(
9718 NodeFailureCode::StaleNativeSessionCatalog,
9719 "native session catalog revision or cursor is stale",
9720 ),
9721 _ => failure(
9722 NodeFailureCode::BackendOperationFailed,
9723 "native session catalog page failed",
9724 ),
9725 })?;
9726 let entries = self.project_native_session_entries(&route, page.entries);
9727 Ok(crate::protocol::NativeSessionCatalogPage {
9728 window: page.window,
9729 revision: page.revision,
9730 entries,
9731 next_after_selection_id: page.next_after_selection_id,
9732 remaining_count: page.remaining_count,
9733 has_more: page.remaining_count > 0,
9734 })
9735 }
9736
9737 async fn preview_native_session(
9738 &self,
9739 selection: NativeSessionSelection,
9740 message_limit: u16,
9741 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9742 selection
9743 .validate()
9744 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9745 let canonical_root = self.native_session_route_root(&selection.route)?;
9746 let registered_workspace_roots = self.workspace_roots();
9747 let provider = selection.route.provider.clone();
9748 let scope = selection.route.scope;
9749 let catalog_revision = selection.catalog_revision;
9750 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9751 let selection_id = selection.selection_id.clone();
9752 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9753 failure(
9754 NodeFailureCode::InvalidRequest,
9755 "native session preview is not configured",
9756 )
9757 })?;
9758 let _permit = Arc::clone(&self.inspection_slots)
9759 .try_acquire_owned()
9760 .map_err(|_| {
9761 failure(
9762 NodeFailureCode::BackendBusy,
9763 "native session preview capacity is busy",
9764 )
9765 })?;
9766 let preview = run_native_session_catalog_operation(
9767 catalog,
9768 "native session preview",
9769 move |catalog| {
9770 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9771 &provider,
9772 scope,
9773 canonical_root.as_deref().map(Path::new),
9774 ®istered_workspace_roots,
9775 catalog_revision,
9776 recent_cutoff_unix_ms,
9777 &selection_id,
9778 message_limit,
9779 ))
9780 },
9781 )
9782 .await?;
9783 preview
9784 .map(Into::into)
9785 .map_err(native_session_preview_failure)
9786 }
9787
9788 async fn index_native_session(
9789 &self,
9790 selection: NativeSessionSelection,
9791 display_name: String,
9792 ) -> Result<ManagedSessionRecord, NodeFailure> {
9793 selection
9794 .validate()
9795 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9796 if selection.route.scope
9797 != gate4agent_types::NativeSessionCatalogScope::Workspace
9798 {
9799 return Err(failure(
9800 NodeFailureCode::WorkspaceRegistrationRequired,
9801 "register the external project as a workspace before indexing or resuming it",
9802 ));
9803 }
9804 let workspace_id = selection
9805 .route
9806 .workspace_id
9807 .clone()
9808 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9809 let canonical_root = self
9810 .native_session_route_root(&selection.route)?
9811 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9812 let registered_workspace_roots = self.workspace_roots();
9813 let provider = selection.route.provider.clone();
9814 let catalog_revision = selection.catalog_revision;
9815 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9816 let selection_id = selection.selection_id.clone();
9817 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9818 failure(
9819 NodeFailureCode::InvalidRequest,
9820 "native session index is not configured",
9821 )
9822 })?;
9823 let _permit = Arc::clone(&self.inspection_slots)
9824 .try_acquire_owned()
9825 .map_err(|_| {
9826 failure(
9827 NodeFailureCode::BackendBusy,
9828 "native session index capacity is busy",
9829 )
9830 })?;
9831 let resolved = run_native_session_catalog_operation(
9832 catalog,
9833 "native session index resolution",
9834 move |catalog| {
9835 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9836 &provider,
9837 gate4agent_types::NativeSessionCatalogScope::Workspace,
9838 Some(Path::new(&canonical_root)),
9839 ®istered_workspace_roots,
9840 catalog_revision,
9841 recent_cutoff_unix_ms,
9842 &selection_id,
9843 ))
9844 },
9845 )
9846 .await?
9847 .map_err(native_session_preview_failure)?;
9848 self.index_provider_session_with_policy(
9849 workspace_id,
9850 selection.route.provider,
9851 resolved.identity,
9852 display_name,
9853 true,
9854 )
9855 }
9856
9857 async fn preview_native_session_by_session_id(
9858 &self,
9859 workspace_id: WorkspaceId,
9860 provider: AgentId,
9861 session_id: String,
9862 message_limit: u16,
9863 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9864 let canonical_root = self.workspace_root(&workspace_id)?;
9865 let registered_workspace_roots = self.workspace_roots();
9866 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9867 failure(
9868 NodeFailureCode::InvalidRequest,
9869 "native session preview is not configured",
9870 )
9871 })?;
9872 let _permit = Arc::clone(&self.inspection_slots)
9873 .try_acquire_owned()
9874 .map_err(|_| {
9875 failure(
9876 NodeFailureCode::BackendBusy,
9877 "native session preview capacity is busy",
9878 )
9879 })?;
9880 run_native_session_catalog_operation(
9881 catalog,
9882 "native session preview",
9883 move |catalog| {
9884 catalog.preview_session_id_for_workspace(
9885 &provider,
9886 Path::new(&canonical_root),
9887 ®istered_workspace_roots,
9888 &session_id,
9889 message_limit,
9890 )
9891 },
9892 )
9893 .await
9894 }
9895
9896 async fn register_workspace(
9897 &self,
9898 workspace_id: WorkspaceId,
9899 root: String,
9900 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9901 validate_workspace_request_root(&root)?;
9902 let canonicalize_workspace_id = workspace_id.clone();
9903 let workspace = tokio::task::spawn_blocking(move || {
9904 WorkspaceConfig::new(canonicalize_workspace_id, root)
9905 })
9906 .await
9907 .map_err(|error| {
9908 failure(
9909 NodeFailureCode::BackendOperationFailed,
9910 &format!("workspace canonicalization task failed: {error}"),
9911 )
9912 })?
9913 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9914 let transaction = self
9915 .state_transaction
9916 .lock()
9917 .unwrap_or_else(|poisoned| poisoned.into_inner());
9918 let mut workspaces = self
9919 .workspaces
9920 .write()
9921 .unwrap_or_else(|poisoned| poisoned.into_inner());
9922 if workspaces.contains_key(&workspace_id) {
9923 return Err(failure(
9924 NodeFailureCode::DuplicateWorkspaceId,
9925 "workspace ID is already registered",
9926 ));
9927 }
9928 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9929 platform::roots_equal(existing_root, workspace.canonical_root())
9930 }) {
9931 return Err(failure(
9932 NodeFailureCode::DuplicateWorkspaceRoot,
9933 &format!("workspace root is already registered as '{existing_id}'"),
9934 ));
9935 }
9936 {
9937 let records = self
9938 .session_records
9939 .lock()
9940 .unwrap_or_else(|poisoned| poisoned.into_inner());
9941 if let Some(record) = records.records.values().find(|record| {
9942 record.workspace_id == workspace_id
9943 && !record
9944 .canonical_root
9945 .as_utf8()
9946 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9947 }) {
9948 return Err(failure(
9949 NodeFailureCode::SessionRecordConflict,
9950 &format!(
9951 "workspace ID is retained by session record '{}' at another root",
9952 record.record_id,
9953 ),
9954 ));
9955 }
9956 }
9957 let snapshot = WorkspaceSnapshot {
9958 workspace_id: workspace_id.clone(),
9959 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9960 sessions: Vec::new(),
9961 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9962 managed_worktree_profiles: Some(WorktreeProfileInventory {
9963 profiles: Vec::new(),
9964 }),
9965 };
9966 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9967 drop(workspaces);
9968 let previous_records = {
9969 let mut records = self
9970 .session_records
9971 .lock()
9972 .unwrap_or_else(|poisoned| poisoned.into_inner());
9973 let previous = records.records.clone();
9974 for record in records.records.values_mut().filter(|record| {
9975 record.workspace_id == workspace_id
9976 && record
9977 .canonical_root
9978 .as_utf8()
9979 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9980 }) {
9981 if record.active_session.is_none() {
9982 record.state = detached_record_state(record);
9983 }
9984 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9985 record.last_error = None;
9986 }
9987 record.updated_at_unix_ms = unix_time_ms();
9988 }
9989 previous
9990 };
9991 if let Err(error) = self.persist_state_locked() {
9992 self.workspaces
9993 .write()
9994 .unwrap_or_else(|poisoned| poisoned.into_inner())
9995 .remove(&workspace_id);
9996 self.session_records
9997 .lock()
9998 .unwrap_or_else(|poisoned| poisoned.into_inner())
9999 .records = previous_records;
10000 return Err(persistence_failure(error));
10001 }
10002 let restored = self
10003 .session_records
10004 .lock()
10005 .unwrap_or_else(|poisoned| poisoned.into_inner())
10006 .records
10007 .values()
10008 .filter(|record| record.workspace_id == workspace_id)
10009 .cloned()
10010 .collect::<Vec<_>>();
10011 drop(transaction);
10012 self.publish(NodeEvent::WorkspaceAdded {
10013 workspace: snapshot.clone(),
10014 });
10015 for record in restored {
10016 self.publish_record(record);
10017 }
10018 Ok(snapshot)
10019 }
10020
10021 async fn create_standalone_workspace(
10022 &self,
10023 workspace_id: WorkspaceId,
10024 root: String,
10025 initial_branch: Option<String>,
10026 ) -> Result<WorkspaceSnapshot, NodeFailure> {
10027 validate_workspace_request_root(&root)?;
10028 validate_git_revision("initial branch", initial_branch.as_deref())?;
10029 let preflight_workspace_id = workspace_id.clone();
10030 let preflight_root = root.clone();
10031 let canonical_candidate = tokio::task::spawn_blocking(move || {
10032 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10033 })
10034 .await
10035 .map_err(|_| failure(
10036 NodeFailureCode::BackendOperationFailed,
10037 "standalone workspace root preflight task failed",
10038 ))?
10039 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10040 {
10041 let workspaces = self
10042 .workspaces
10043 .read()
10044 .unwrap_or_else(|poisoned| poisoned.into_inner());
10045 if workspaces.contains_key(&workspace_id) {
10046 return Err(failure(
10047 NodeFailureCode::DuplicateWorkspaceId,
10048 "workspace ID is already registered",
10049 ));
10050 }
10051 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10052 platform::roots_equal(existing_root, &canonical_candidate)
10053 }) {
10054 return Err(failure(
10055 NodeFailureCode::DuplicateWorkspaceRoot,
10056 &format!("workspace root is already registered as '{existing_id}'"),
10057 ));
10058 }
10059 }
10060 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10061 .await
10062 .map_err(standalone_workspace_failure)?;
10063 prepared
10064 .verify_for_registration()
10065 .await
10066 .map_err(standalone_workspace_failure)?;
10067 match self
10068 .register_workspace(workspace_id, prepared.root().to_owned())
10069 .await
10070 {
10071 Ok(workspace) => Ok(workspace),
10072 Err(registration_error) => {
10073 if prepared.compensate_registration_failure().await {
10074 Err(registration_error)
10075 } else {
10076 Err(failure(
10077 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10078 "standalone workspace initialization succeeded but registration failed; recovery is required",
10079 ))
10080 }
10081 }
10082 }
10083 }
10084
10085 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10086 let bindings = self
10087 .session_bindings
10088 .lock()
10089 .unwrap_or_else(|poisoned| poisoned.into_inner());
10090 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10091 return Err(failure(
10092 NodeFailureCode::WorkspaceBusy,
10093 "workspace retains a bound session; remove every session before unregistering it",
10094 ));
10095 }
10096 drop(bindings);
10097 let transaction = self
10098 .state_transaction
10099 .lock()
10100 .unwrap_or_else(|poisoned| poisoned.into_inner());
10101 let mut workspaces = self
10102 .workspaces
10103 .write()
10104 .unwrap_or_else(|poisoned| poisoned.into_inner());
10105 if !workspaces.contains_key(workspace_id) {
10106 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10107 }
10108 if workspaces.len() == 1 {
10109 return Err(failure(
10110 NodeFailureCode::LastWorkspace,
10111 "node must retain at least one workspace",
10112 ));
10113 }
10114 let removed_root = workspaces
10115 .remove(workspace_id)
10116 .expect("workspace presence was validated");
10117 drop(workspaces);
10118 let previous_records = {
10119 let mut records = self
10120 .session_records
10121 .lock()
10122 .unwrap_or_else(|poisoned| poisoned.into_inner());
10123 let previous = records.records.clone();
10124 for record in records
10125 .records
10126 .values_mut()
10127 .filter(|record| &record.workspace_id == workspace_id)
10128 {
10129 record.active_session = None;
10130 record.state = ManagedSessionState::Unavailable;
10131 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10132 record.updated_at_unix_ms = unix_time_ms();
10133 }
10134 previous
10135 };
10136 if let Err(error) = self.persist_state_locked() {
10137 self.workspaces
10138 .write()
10139 .unwrap_or_else(|poisoned| poisoned.into_inner())
10140 .insert(workspace_id.clone(), removed_root);
10141 self.session_records
10142 .lock()
10143 .unwrap_or_else(|poisoned| poisoned.into_inner())
10144 .records = previous_records;
10145 return Err(persistence_failure(error));
10146 }
10147 let unavailable = self
10148 .session_records
10149 .lock()
10150 .unwrap_or_else(|poisoned| poisoned.into_inner())
10151 .records
10152 .values()
10153 .filter(|record| &record.workspace_id == workspace_id)
10154 .cloned()
10155 .collect::<Vec<_>>();
10156 drop(transaction);
10157 self.publish(NodeEvent::WorkspaceRemoved {
10158 workspace_id: workspace_id.clone(),
10159 });
10160 for record in unavailable {
10161 self.publish_record(record);
10162 }
10163 Ok(())
10164 }
10165
10166 async fn create_worktree(
10167 &self,
10168 source_workspace_id: WorkspaceId,
10169 workspace_id: WorkspaceId,
10170 target_root: String,
10171 branch: String,
10172 base: Option<String>,
10173 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10174 self.require_worktree_service(&source_workspace_id)?;
10175 validate_workspace_request_root(&target_root)?;
10176 validate_git_revision("worktree base", base.as_deref())?;
10177 let source_root = self.workspace_root(&source_workspace_id)?;
10178 {
10179 let workspaces = self
10180 .workspaces
10181 .read()
10182 .unwrap_or_else(|poisoned| poisoned.into_inner());
10183 if workspaces.contains_key(&workspace_id) {
10184 return Err(failure(
10185 NodeFailureCode::DuplicateWorkspaceId,
10186 "workspace ID is already registered",
10187 ));
10188 }
10189 if let Some((existing_id, _)) = workspaces
10190 .iter()
10191 .find(|(_, root)| worktree_paths_equal(root, &target_root))
10192 {
10193 return Err(failure(
10194 NodeFailureCode::DuplicateWorkspaceRoot,
10195 &format!("workspace root is already registered as '{existing_id}'"),
10196 ));
10197 }
10198 }
10199 let mut worktree = create_git_worktree(
10200 &source_root,
10201 &target_root,
10202 &branch,
10203 base.as_deref(),
10204 )
10205 .await
10206 .map_err(git_worktree_failure)?;
10207 let workspace = match self
10208 .register_workspace(workspace_id, worktree.path.clone())
10209 .await
10210 {
10211 Ok(workspace) => workspace,
10212 Err(registration_error) => {
10213 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10214 return Err(failure(
10215 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10216 "worktree registration failed and compensation requires recovery",
10217 ));
10218 }
10219 return Err(failure(
10220 registration_error.code,
10221 ®istration_error.message,
10222 ));
10223 }
10224 };
10225 worktree.workspace_id = Some(workspace.workspace_id.clone());
10226 Ok((protocol_worktree(worktree), workspace))
10227 }
10228
10229 async fn remove_worktree(
10230 &self,
10231 source_workspace_id: WorkspaceId,
10232 target_root: String,
10233 ) -> Result<Option<WorkspaceId>, NodeFailure> {
10234 self.require_worktree_service(&source_workspace_id)?;
10235 validate_workspace_request_root(&target_root)?;
10236 let source_root = self.workspace_root(&source_workspace_id)?;
10237 let target_root = normalize_worktree_removal_target(&target_root)
10238 .map_err(git_worktree_failure)?;
10239 let target = list_git_worktrees(&source_root)
10240 .await
10241 .map_err(git_worktree_failure)?
10242 .into_iter()
10243 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10244 .ok_or_else(|| {
10245 failure(
10246 NodeFailureCode::WorktreeProtected,
10247 "refusing to remove a path that is not in Git's worktree listing",
10248 )
10249 })?;
10250 let registered_workspace_id = {
10251 let workspaces = self
10252 .workspaces
10253 .read()
10254 .unwrap_or_else(|poisoned| poisoned.into_inner());
10255 let matched = workspaces
10256 .iter()
10257 .find(|(_, root)| worktree_paths_equal(root, &target.path))
10258 .map(|(workspace_id, _)| workspace_id.clone());
10259 if matched.is_some() && workspaces.len() == 1 {
10260 return Err(failure(
10261 NodeFailureCode::LastWorkspace,
10262 "node must retain at least one workspace",
10263 ));
10264 }
10265 matched
10266 };
10267 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10268 let bindings = self
10269 .session_bindings
10270 .lock()
10271 .unwrap_or_else(|poisoned| poisoned.into_inner());
10272 if bindings
10273 .values()
10274 .any(|binding| &binding.workspace_id == workspace_id)
10275 {
10276 return Err(failure(
10277 NodeFailureCode::WorkspaceBusy,
10278 "worktree workspace retains a bound session; remove every session first",
10279 ));
10280 }
10281 }
10282 remove_git_worktree(&source_root, &target.path)
10283 .await
10284 .map_err(git_worktree_failure)?;
10285 if let Some(workspace_id) = registered_workspace_id.as_ref() {
10286 self.unregister_workspace(workspace_id)
10287 .map_err(|error| failure(error.code, &error.message))?;
10288 }
10289 Ok(registered_workspace_id)
10290 }
10291
10292 async fn cleanup_managed_worktree(
10293 &self,
10294 lease_id: &ManagedWorktreeLeaseId,
10295 explicit: bool,
10296 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10297 let lease = self.managed_worktrees.lock()
10298 .unwrap_or_else(|poisoned| poisoned.into_inner())
10299 .get(lease_id)
10300 .cloned()
10301 .ok_or_else(|| failure(
10302 NodeFailureCode::UnknownManagedWorktreeLease,
10303 "managed worktree lease does not exist",
10304 ))?;
10305 if lease.state == ManagedWorktreeLeaseState::Removed {
10306 return Ok(lease.snapshot());
10307 }
10308 if lease.has_holders() {
10309 return Err(failure(
10310 NodeFailureCode::ManagedWorktreeBusy,
10311 "managed worktree lease still has a session or durable-record holder",
10312 ));
10313 }
10314 if self
10315 .materializations
10316 .lock()
10317 .unwrap_or_else(|poisoned| poisoned.into_inner())
10318 .values()
10319 .any(|record| record.managed_lease_id() == Some(lease_id))
10320 {
10321 return Err(failure(
10322 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10323 "managed worktree cleanup is blocked by session environment ownership",
10324 ));
10325 }
10326 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10327 let snapshot = {
10328 let mut registry = self.managed_worktrees.lock()
10329 .unwrap_or_else(|poisoned| poisoned.into_inner());
10330 let current = registry.get_mut(lease_id)
10331 .expect("managed worktree lease remains present");
10332 current.state = ManagedWorktreeLeaseState::Retained;
10333 current.cleanup_failure = None;
10334 current.updated_at_unix_ms = unix_time_ms();
10335 current.snapshot()
10336 };
10337 if self.persist_state().is_err() {
10338 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10339 }
10340 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10341 return Ok(snapshot);
10342 }
10343 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10344 if profile.revision() != &lease.profile_revision {
10345 self.mark_managed_recovery_required(
10346 lease_id,
10347 ManagedWorktreeCleanupFailure::OwnershipConflict,
10348 );
10349 return Err(failure(
10350 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10351 "managed worktree profile revision changed",
10352 ));
10353 }
10354 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10355 self.mark_managed_recovery_required(
10356 lease_id,
10357 ManagedWorktreeCleanupFailure::OwnershipConflict,
10358 );
10359 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10360 })?;
10361 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10362 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10363 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10364 managed_git_worktree_failure(error)
10365 })?;
10366 let target = listed.iter()
10367 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10368 if let Some(target) = target {
10369 if target.is_main || target.is_bare || !exact_owned_worktree(
10370 &lease,
10371 &target.path,
10372 target.branch.as_deref(),
10373 &target.head,
10374 ) {
10375 self.mark_managed_recovery_required(
10376 lease_id,
10377 ManagedWorktreeCleanupFailure::OwnershipConflict,
10378 );
10379 return Err(failure(
10380 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10381 "Git worktree identity no longer matches the managed lease",
10382 ));
10383 }
10384 if target.prunable {
10385 self.mark_managed_cleanup_blocked(
10386 lease_id,
10387 ManagedWorktreeCleanupFailure::Prunable,
10388 );
10389 return Err(failure(
10390 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10391 "managed worktree is prunable and requires recovery",
10392 ));
10393 }
10394 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10395 let failure_kind = match error.kind {
10396 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10397 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10398 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10399 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10400 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10401 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10402 };
10403 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10404 self.mark_managed_recovery_required(lease_id, failure_kind);
10405 } else {
10406 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10407 }
10408 return Err(managed_git_worktree_failure(error));
10409 }
10410 } else {
10411 match std::fs::symlink_metadata(&lease.target_root) {
10412 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10413 Ok(_) => {
10414 self.mark_managed_recovery_required(
10415 lease_id,
10416 ManagedWorktreeCleanupFailure::OwnershipConflict,
10417 );
10418 return Err(failure(
10419 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10420 "managed target exists but is not owned by Git",
10421 ));
10422 }
10423 Err(_) => {
10424 self.mark_managed_cleanup_blocked(
10425 lease_id,
10426 ManagedWorktreeCleanupFailure::Backend,
10427 );
10428 return Err(failure(
10429 NodeFailureCode::BackendOperationFailed,
10430 "managed target identity could not be inspected",
10431 ));
10432 }
10433 }
10434 }
10435 let registered = self.workspaces.read()
10436 .unwrap_or_else(|poisoned| poisoned.into_inner())
10437 .contains_key(&lease.workspace_id);
10438 if registered {
10439 self.unregister_workspace(&lease.workspace_id)?;
10440 }
10441 let removed = {
10442 self.managed_worktrees.lock()
10443 .unwrap_or_else(|poisoned| poisoned.into_inner())
10444 .tombstone(lease_id, unix_time_ms())
10445 .expect("managed worktree lease remains present during cleanup")
10446 };
10447 self.persist_state().map_err(persistence_failure)?;
10448 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10449 Ok(removed.snapshot())
10450 }
10451
10452 fn mark_managed_cleanup_blocked(
10453 &self,
10454 lease_id: &ManagedWorktreeLeaseId,
10455 failure_kind: ManagedWorktreeCleanupFailure,
10456 ) {
10457 let snapshot = {
10458 let mut registry = self.managed_worktrees.lock()
10459 .unwrap_or_else(|poisoned| poisoned.into_inner());
10460 let Some(lease) = registry.get_mut(lease_id) else { return };
10461 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10462 lease.cleanup_failure = Some(failure_kind);
10463 lease.updated_at_unix_ms = unix_time_ms();
10464 lease.snapshot()
10465 };
10466 let _ = self.persist_state();
10467 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10468 }
10469
10470 fn require_worktree_service(
10471 &self,
10472 source_workspace_id: &WorkspaceId,
10473 ) -> Result<(), NodeFailure> {
10474 if self.managed_worktrees.lock()
10475 .unwrap_or_else(|poisoned| poisoned.into_inner())
10476 .lease_for_workspace(source_workspace_id)
10477 .is_some()
10478 {
10479 return Err(failure(
10480 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10481 "managed worktree targets are internal-only workspaces",
10482 ));
10483 }
10484 match self
10485 .worktree_service_modes
10486 .get(source_workspace_id)
10487 .copied()
10488 .unwrap_or(WorktreeServiceMode::Manual)
10489 {
10490 WorktreeServiceMode::Manual => Ok(()),
10491 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10492 NodeFailureCode::UnsupportedSpawnCapability,
10493 "Git worktree service mode is unavailable in this node build",
10494 )),
10495 }
10496 }
10497
10498 fn managed_reservation_conflict(
10499 &self,
10500 workspace_id: Option<&WorkspaceId>,
10501 root: Option<&str>,
10502 ) -> bool {
10503 self.managed_worktrees.lock()
10504 .unwrap_or_else(|poisoned| poisoned.into_inner())
10505 .active_records()
10506 .any(|lease| {
10507 workspace_id.is_some_and(|workspace_id| {
10508 workspace_id == &lease.source_workspace_id
10509 || workspace_id == &lease.workspace_id
10510 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10511 })
10512 }
10513
10514 fn reject_managed_reservation(
10515 &self,
10516 workspace_id: Option<&WorkspaceId>,
10517 root: Option<&str>,
10518 ) -> Result<(), NodeFailure> {
10519 if self.managed_reservation_conflict(workspace_id, root) {
10520 Err(failure(
10521 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10522 "managed worktree ownership may only be mutated through its lease cleanup",
10523 ))
10524 } else {
10525 Ok(())
10526 }
10527 }
10528
10529 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10530 self.validate_address(address)?;
10531 self.workspace_root(&address.workspace_id)
10532 }
10533
10534 fn require_session_runtime_policy(
10568 &self,
10569 address: &SessionAddress,
10570 requirement: ProviderRuntimeRequirement,
10571 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10572 let runtime_policy = {
10573 let bindings = self
10574 .session_bindings
10575 .lock()
10576 .unwrap_or_else(|poisoned| poisoned.into_inner());
10577 let binding = bindings
10578 .get(&address.session.instance_id)
10579 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10580 if binding.workspace_id != address.workspace_id {
10581 return Err(failure(
10582 NodeFailureCode::SessionWorkspaceMismatch,
10583 "session belongs to another workspace",
10584 ));
10585 }
10586 if binding.generation != address.session.generation {
10587 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10588 }
10589 binding.runtime_policy
10590 };
10591 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10592 let snapshot = self.internal_session_snapshot(address)?;
10593 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10594 && snapshot.provider.lead_activity != ProviderActivity::Idle
10595 {
10596 return Err(failure(
10597 NodeFailureCode::TurnInFlight,
10598 "session already has a turn in flight; wait for it to complete before sending another prompt",
10599 ));
10600 }
10601 }
10602 require_policy(runtime_policy, false, false, requirement)
10603 .map(|()| runtime_policy)
10604 .map_err(|_| failure(
10605 NodeFailureCode::UnsupportedCapability,
10606 "session runtime policy does not admit this semantic operation",
10607 ))
10608 }
10609
10610 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10611 let mut bindings = self
10612 .session_bindings
10613 .lock()
10614 .unwrap_or_else(|poisoned| poisoned.into_inner());
10615 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10616 binding.workspace_id == address.workspace_id
10617 && binding.generation == address.session.generation
10618 }) {
10619 let removed = bindings.remove(&address.session.instance_id);
10620 self.clear_terminal_frame_watermark(address);
10621 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10622 return removed;
10623 }
10624 None
10625 }
10626
10627 fn cleanup_session_owned_materialization(
10628 &self,
10629 address: &SessionAddress,
10630 binding: &SessionBinding,
10631 ) -> Result<(), NodeFailure> {
10632 let Some(id) = binding.materialization_id.as_ref() else {
10633 return Ok(());
10634 };
10635 let owner = self
10636 .materializations
10637 .lock()
10638 .unwrap_or_else(|poisoned| poisoned.into_inner())
10639 .get(id)
10640 .map(|record| record.owner().clone());
10641 match owner {
10642 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10643 Some(MaterializationOwner::Session {
10644 incarnation_id,
10645 instance_id,
10646 generation,
10647 }) if incarnation_id == self.incarnation_id
10648 && instance_id == address.session.instance_id
10649 && generation == address.session.generation =>
10650 {
10651 self.cleanup_materialization(id)
10652 }
10653 Some(MaterializationOwner::Session { .. }) => Err(failure(
10654 NodeFailureCode::BackendOperationFailed,
10655 "session environment ownership requires recovery",
10656 )),
10657 }
10658 }
10659
10660 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10661 let mut watermarks = self
10662 .terminal_frame_watermarks
10663 .lock()
10664 .unwrap_or_else(|poisoned| poisoned.into_inner());
10665 if watermarks
10666 .get(&address.session.instance_id)
10667 .is_some_and(|(current, _)| current == address)
10668 {
10669 watermarks.remove(&address.session.instance_id);
10670 }
10671 }
10672
10673 #[cfg(test)]
10674 fn address_for(
10675 &self,
10676 instance_id: AgentInstanceId,
10677 generation: SessionGeneration,
10678 ) -> Option<SessionAddress> {
10679 let binding = self.session_bindings
10680 .lock()
10681 .unwrap_or_else(|poisoned| poisoned.into_inner())
10682 .get(&instance_id)
10683 .cloned()?;
10684 (binding.generation == generation).then_some(SessionAddress {
10685 workspace_id: binding.workspace_id,
10686 session: SessionKey { instance_id, generation },
10687 })
10688 }
10689
10690 fn arm_resume(
10691 &self,
10692 address: &SessionAddress,
10693 command_id: CommandId,
10694 runtime_policy: ProviderRuntimePolicy,
10695 ) -> Result<(), NodeFailure> {
10696 let mut bindings = self
10697 .session_bindings
10698 .lock()
10699 .unwrap_or_else(|poisoned| poisoned.into_inner());
10700 let binding = bindings
10701 .get_mut(&address.session.instance_id)
10702 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10703 if binding.workspace_id != address.workspace_id {
10704 return Err(failure(
10705 NodeFailureCode::SessionWorkspaceMismatch,
10706 "session belongs to another workspace",
10707 ));
10708 }
10709 if binding.generation != address.session.generation {
10710 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10711 }
10712 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10713 Ok(())
10714 }
10715
10716 fn clear_armed_resume(&self, address: &SessionAddress) {
10717 let mut bindings = self
10718 .session_bindings
10719 .lock()
10720 .unwrap_or_else(|poisoned| poisoned.into_inner());
10721 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10722 if binding.workspace_id == address.workspace_id
10723 && binding.generation == address.session.generation
10724 {
10725 binding.pending_resume = None;
10726 }
10727 }
10728 }
10729
10730 fn publish_control(&self, event: ControlEvent) {
10731 let revokes_harness_mcp = matches!(
10732 &event.event,
10733 ControlEventKind::Exited { .. }
10734 | ControlEventKind::Failed { .. }
10735 | ControlEventKind::Removed
10736 );
10737 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10738 let mut bindings = self
10739 .session_bindings
10740 .lock()
10741 .unwrap_or_else(|poisoned| poisoned.into_inner());
10742 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10743 return;
10744 };
10745 let previous_address = SessionAddress {
10746 workspace_id: binding.workspace_id.clone(),
10747 session: SessionKey {
10748 instance_id: event.instance_id,
10749 generation: binding.generation,
10750 },
10751 };
10752 let mut resume_command_rejected = false;
10753 if binding.generation == event.generation {
10754 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10755 let rejected_resume = matches!(
10756 &event.event,
10757 ControlEventKind::CommandRejected { .. }
10758 ) && event.command_id == Some(pending_command_id);
10759 let resume_failed = matches!(
10760 &event.event,
10761 ControlEventKind::ResumeDenied { .. }
10762 | ControlEventKind::ResumeFailed { .. }
10763 );
10764 if pending_generation == binding.generation
10765 && (rejected_resume || resume_failed)
10766 {
10767 resume_command_rejected = rejected_resume;
10768 binding.pending_resume = None;
10769 }
10770 }
10771 } else {
10772 let expected = binding.generation.0.checked_add(1);
10773 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10774 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10775 generation == binding.generation
10776 })
10777 || expected != Some(event.generation.0)
10778 || !authorized
10779 {
10780 return;
10781 }
10782 let (_, _, runtime_policy) = binding
10783 .pending_resume
10784 .expect("authorized resume retains its pending runtime policy");
10785 binding.generation = event.generation;
10786 binding.runtime_policy = runtime_policy;
10787 binding.pending_resume = None;
10788 }
10789 (
10790 SessionAddress {
10791 workspace_id: binding.workspace_id.clone(),
10792 session: SessionKey {
10793 instance_id: event.instance_id,
10794 generation: event.generation,
10795 },
10796 },
10797 (previous_address.session.generation != event.generation)
10798 .then_some(previous_address),
10799 binding.record_id.clone(),
10800 binding.runtime_policy.provider_session_identity,
10801 resume_command_rejected,
10802 )
10803 };
10804 if let Some(previous_address) = previous_address.as_ref() {
10805 self.clear_terminal_frame_watermark(previous_address);
10806 }
10807 if let Some(record_id) = record_id {
10808 self.reconcile_managed_record(
10809 &record_id,
10810 &address,
10811 &event,
10812 identity_admitted,
10813 resume_command_rejected,
10814 );
10815 }
10816 match &event.event {
10824 ControlEventKind::Running { process_id } => {
10825 if let Some(pid) = process_id {
10826 crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10827 }
10828 #[cfg(unix)]
10829 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10830 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10831 }
10832 tracing::info!(
10833 node_id = %self.node_id,
10834 workspace_id = %address.workspace_id,
10835 session = ?address.session,
10836 pid = ?process_id,
10837 "session process is running",
10838 );
10839 }
10840 ControlEventKind::Exited { exit_code, forced } => {
10841 crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10842 #[cfg(unix)]
10843 if let Some(proxy) = self.signer_proxy.get().cloned() {
10844 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10845 if proxy.has_session(&key) {
10846 std::thread::spawn(move || proxy.release(&key));
10848 }
10849 }
10850 tracing::info!(
10851 node_id = %self.node_id,
10852 workspace_id = %address.workspace_id,
10853 session = ?address.session,
10854 exit_code = ?exit_code,
10855 forced = forced,
10856 "session process exited",
10857 );
10858 }
10859 ControlEventKind::Failed { message } => {
10860 tracing::warn!(
10861 node_id = %self.node_id,
10862 workspace_id = %address.workspace_id,
10863 session = ?address.session,
10864 cause = %message,
10865 "session failed",
10866 );
10867 }
10868 ControlEventKind::CommandRejected { message } => {
10869 tracing::warn!(
10870 node_id = %self.node_id,
10871 workspace_id = %address.workspace_id,
10872 session = ?address.session,
10873 cause = %message,
10874 "session command rejected",
10875 );
10876 }
10877 ControlEventKind::Removed => {
10878 tracing::info!(
10879 node_id = %self.node_id,
10880 workspace_id = %address.workspace_id,
10881 session = ?address.session,
10882 "session removed",
10883 );
10884 }
10885 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10886 tracing::info!(
10887 node_id = %self.node_id,
10888 workspace_id = %address.workspace_id,
10889 session = ?address.session,
10890 provider_session = ?provider_session,
10891 "session resume authorized",
10892 );
10893 }
10894 ControlEventKind::Resumed { session: provider_session, process_id } => {
10895 tracing::info!(
10896 node_id = %self.node_id,
10897 workspace_id = %address.workspace_id,
10898 session = ?address.session,
10899 provider_session = ?provider_session,
10900 pid = ?process_id,
10901 "session resumed",
10902 );
10903 }
10904 ControlEventKind::ResumeDenied { reason } => {
10905 tracing::warn!(
10906 node_id = %self.node_id,
10907 workspace_id = %address.workspace_id,
10908 session = ?address.session,
10909 cause = %reason,
10910 "session resume denied",
10911 );
10912 }
10913 ControlEventKind::ResumeFailed { message } => {
10914 tracing::warn!(
10915 node_id = %self.node_id,
10916 workspace_id = %address.workspace_id,
10917 session = ?address.session,
10918 cause = %message,
10919 "session resume failed",
10920 );
10921 }
10922 _ => {}
10923 }
10924 self.apply_pending_acp_options(&address, &event);
10925 self.apply_acp_auto_permit(&address, &event);
10926 trace_acp_turn(&address, &event);
10927 self.observe_acp_provider_error(&address, &event);
10928 let stream_chunk = agent_stream_chunk(&event);
10929 self.publish(NodeEvent::Control {
10930 address: address.clone(),
10931 event,
10932 });
10933 if let Some(chunk) = stream_chunk {
10934 self.publish_agent_stream_chunk(address.clone(), chunk);
10935 }
10936 if revokes_harness_mcp {
10937 SessionLifecycleHook::on_session_ended(self, &address);
10938 }
10939 }
10940
10941 fn reconcile_managed_record(
10942 &self,
10943 record_id: &SessionRecordId,
10944 address: &SessionAddress,
10945 event: &ControlEvent,
10946 identity_admitted: bool,
10947 resume_command_rejected: bool,
10948 ) {
10949 let _transaction = self
10950 .state_transaction
10951 .lock()
10952 .unwrap_or_else(|poisoned| poisoned.into_inner());
10953 let observed_identity = match &event.event {
10954 ControlEventKind::ProviderEvent {
10955 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10956 ..
10957 } if identity_admitted => Some(identity.clone()),
10958 _ => None,
10959 };
10960 let identity_was_observed = observed_identity.is_some();
10961 let replacement_id = observed_identity
10962 .as_ref()
10963 .and_then(|_| self.allocate_record_id().ok());
10964 let mut upserts = Vec::new();
10965 let mut removals = Vec::new();
10966 let mut rebind = None;
10967 let original_records;
10968 let original_materializations = self
10969 .materializations
10970 .lock()
10971 .unwrap_or_else(|poisoned| poisoned.into_inner())
10972 .clone();
10973 {
10974 let mut records = self
10975 .session_records
10976 .lock()
10977 .unwrap_or_else(|poisoned| poisoned.into_inner());
10978 original_records = records.records.clone();
10979 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10980 return;
10981 };
10982 if let Some(identity) = observed_identity {
10983 let existing_id = records
10984 .records
10985 .values()
10986 .find(|record| {
10987 record.record_id != *record_id
10988 && record.provider_session.as_ref().is_some_and(|record_identity| {
10989 session_registry::same_provider_session(
10990 &record.provider,
10991 record_identity,
10992 ¤t_snapshot.provider,
10993 &identity,
10994 )
10995 })
10996 })
10997 .map(|record| record.record_id.clone());
10998 if let Some(existing_id) = existing_id {
10999 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
11000 record.mode == current_snapshot.mode
11001 && record.workspace_id == current_snapshot.workspace_id
11002 && record.environment_profile == current_snapshot.environment_profile
11003 && record.bundle == current_snapshot.bundle
11004 && record.context_id == current_snapshot.context_id
11005 && record.context == current_snapshot.context
11006 && record
11007 .canonical_root
11008 .as_utf8()
11009 .zip(current_snapshot.canonical_root.as_utf8())
11010 .is_some_and(|(left, right)| platform::roots_equal(left, right))
11011 });
11012 if !scope_matches {
11013 if let Some(current) = records.records.get_mut(record_id) {
11014 current.state = ManagedSessionState::Unavailable;
11015 current.last_error =
11016 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11017 current.updated_at_unix_ms = unix_time_ms();
11018 upserts.push(current.clone());
11019 }
11020 } else {
11021 let existing_is_active = records
11022 .records
11023 .get(&existing_id)
11024 .is_some_and(|record| record.active_session.is_some());
11025 if existing_is_active {
11026 if let Some(current) = records.records.get_mut(record_id) {
11027 current.state = ManagedSessionState::Unavailable;
11028 current.last_error =
11029 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11030 current.updated_at_unix_ms = unix_time_ms();
11031 upserts.push(current.clone());
11032 }
11033 } else {
11034 if current_snapshot.provider_session.is_some() {
11035 if let Some(current) = records.records.get_mut(record_id) {
11036 current.active_session = None;
11037 current.state = ManagedSessionState::Dormant;
11038 current.updated_at_unix_ms = unix_time_ms();
11039 upserts.push(current.clone());
11040 }
11041 } else {
11042 records.records.remove(record_id);
11043 removals.push(record_id.clone());
11044 }
11045 if let Some(existing) = records.records.get_mut(&existing_id) {
11046 existing.provider_session = Some(identity.clone());
11047 existing.active_session = Some(address.clone());
11048 existing.state = ManagedSessionState::Live;
11049 existing.updated_at_unix_ms = unix_time_ms();
11050 existing.last_error = None;
11051 upserts.push(existing.clone());
11052 }
11053 rebind = Some(existing_id);
11054 }
11055 }
11056 } else if current_snapshot
11057 .provider_session
11058 .as_ref()
11059 .is_some_and(|current| {
11060 !session_registry::same_provider_session(
11061 ¤t_snapshot.provider,
11062 current,
11063 ¤t_snapshot.provider,
11064 &identity,
11065 )
11066 })
11067 {
11068 if let Some(current) = records.records.get_mut(record_id) {
11069 current.active_session = None;
11070 if current.bundle.is_some() || current.context.is_some() {
11071 let had_bundle = current.bundle.is_some();
11072 current.provider_session = None;
11073 current.bundle = None;
11074 current.context_id = None;
11075 current.context = None;
11076 current.state = ManagedSessionState::Unavailable;
11077 current.last_error = Some(if had_bundle {
11078 "bundle-unavailable".to_owned()
11079 } else {
11080 "context-unavailable".to_owned()
11081 });
11082 } else {
11083 current.state = ManagedSessionState::Dormant;
11084 }
11085 current.updated_at_unix_ms = unix_time_ms();
11086 upserts.push(current.clone());
11087 }
11088 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11089 if let Some(current) = records.records.get_mut(record_id) {
11090 current.state = ManagedSessionState::Unavailable;
11091 current.last_error =
11092 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11093 current.updated_at_unix_ms = unix_time_ms();
11094 upserts.push(current.clone());
11095 }
11096 } else if let Some(new_record_id) = replacement_id {
11097 let now = unix_time_ms();
11098 let replacement = ManagedSessionRecord {
11099 record_id: new_record_id.clone(),
11100 display_name: format!(
11101 "{} #{}",
11102 current_snapshot.provider.as_str(),
11103 records
11104 .records
11105 .values()
11106 .filter(|record| {
11107 record.provider == current_snapshot.provider
11108 })
11109 .count()
11110 .saturating_add(1),
11111 ),
11112 provider: current_snapshot.provider.clone(),
11113 mode: current_snapshot.mode,
11114 state: ManagedSessionState::Live,
11115 workspace_id: current_snapshot.workspace_id,
11116 canonical_root: current_snapshot.canonical_root,
11117 provider_session: Some(identity),
11118 active_session: Some(address.clone()),
11119 environment_profile: current_snapshot.environment_profile,
11120 bundle: current_snapshot.bundle,
11121 context_id: current_snapshot.context_id,
11122 context: current_snapshot.context,
11123 exported_context: None,
11129 task_binding: current_snapshot.task_binding,
11130 created_at_unix_ms: now,
11131 updated_at_unix_ms: now,
11132 last_error: None,
11133 };
11134 records
11135 .records
11136 .insert(new_record_id.clone(), replacement.clone());
11137 upserts.push(replacement);
11138 rebind = Some(new_record_id);
11139 } else if let Some(current) = records.records.get_mut(record_id) {
11140 current.state = ManagedSessionState::Unavailable;
11141 current.last_error =
11142 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11143 upserts.push(current.clone());
11144 }
11145 } else if let Some(current) = records.records.get_mut(record_id) {
11146 current.provider_session = Some(identity);
11147 current.active_session = Some(address.clone());
11148 current.state = ManagedSessionState::Live;
11149 current.updated_at_unix_ms = unix_time_ms();
11150 current.last_error = None;
11151 upserts.push(current.clone());
11152 }
11153 } else if let Some(current) = records.records.get_mut(record_id) {
11154 if resume_command_rejected {
11155 current.active_session = None;
11156 current.state = detached_record_state(current);
11157 current.updated_at_unix_ms = unix_time_ms();
11158 current.last_error =
11159 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11160 upserts.push(current.clone());
11161 } else {
11162 match &event.event {
11163 ControlEventKind::Running { .. }
11164 | ControlEventKind::ResumeAuthorized { .. }
11165 | ControlEventKind::Resumed { .. } => {
11166 current.active_session = Some(address.clone());
11167 current.state = active_record_state(
11168 identity_admitted,
11169 current.provider_session.is_some(),
11170 );
11171 current.updated_at_unix_ms = unix_time_ms();
11172 current.last_error = None;
11173 upserts.push(current.clone());
11174 }
11175 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11176 current.active_session = None;
11177 current.state = detached_record_state(current);
11178 current.updated_at_unix_ms = unix_time_ms();
11179 upserts.push(current.clone());
11180 }
11181 ControlEventKind::Failed { message }
11182 | ControlEventKind::ResumeFailed { message } => {
11183 current.active_session = None;
11184 current.state = detached_record_state(current);
11185 current.updated_at_unix_ms = unix_time_ms();
11186 current.last_error = Some(
11187 session_registry::sanitized_record_error_summary(message),
11188 );
11189 upserts.push(current.clone());
11190 }
11191 ControlEventKind::ResumeDenied { reason } => {
11192 current.active_session = None;
11193 current.state = detached_record_state(current);
11194 current.updated_at_unix_ms = unix_time_ms();
11195 current.last_error = Some(
11196 session_registry::sanitized_record_error_summary(reason),
11197 );
11198 upserts.push(current.clone());
11199 }
11200 _ => {}
11201 }
11202 }
11203 }
11204 }
11205 let materialization_owner_record = identity_was_observed.then(|| {
11206 rebind.clone().unwrap_or_else(|| record_id.clone())
11207 }).and_then(|candidate| {
11208 self.session_records
11209 .lock()
11210 .unwrap_or_else(|poisoned| poisoned.into_inner())
11211 .records
11212 .get(&candidate)
11213 .filter(|record| {
11214 record.provider_session.is_some()
11215 && record.active_session.as_ref() == Some(address)
11216 })
11217 .map(|_| candidate)
11218 });
11219 if let Some(owner_record_id) = materialization_owner_record {
11220 let materialization_id = self
11221 .session_bindings
11222 .lock()
11223 .unwrap_or_else(|poisoned| poisoned.into_inner())
11224 .get(&address.session.instance_id)
11225 .and_then(|binding| binding.materialization_id.clone());
11226 if let Some(materialization_id) = materialization_id {
11227 let mut transfer_failed = false;
11228 {
11229 let mut materializations = self
11230 .materializations
11231 .lock()
11232 .unwrap_or_else(|poisoned| poisoned.into_inner());
11233 if let Some(ownership) = materializations.get_mut(&materialization_id) {
11234 match ownership.owner() {
11235 MaterializationOwner::Session {
11236 incarnation_id,
11237 instance_id,
11238 generation,
11239 } if *incarnation_id == self.incarnation_id
11240 && *instance_id == address.session.instance_id
11241 && *generation == address.session.generation =>
11242 {
11243 transfer_failed = ownership
11244 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11245 .is_err();
11246 }
11247 MaterializationOwner::Record { record_id }
11248 if record_id == &owner_record_id => {}
11249 MaterializationOwner::Record { record_id } => {
11250 let expected = record_id.clone();
11251 transfer_failed = ownership
11252 .transfer_record_owner(
11253 &expected,
11254 owner_record_id.clone(),
11255 unix_time_ms(),
11256 )
11257 .is_err();
11258 }
11259 _ => transfer_failed = true,
11260 }
11261 if transfer_failed {
11262 let _ = ownership.mark_recovery_required(unix_time_ms());
11263 }
11264 } else {
11265 transfer_failed = true;
11266 }
11267 }
11268 if transfer_failed {
11269 if let Some(record) = self
11270 .session_records
11271 .lock()
11272 .unwrap_or_else(|poisoned| poisoned.into_inner())
11273 .records
11274 .get_mut(&owner_record_id)
11275 {
11276 record.state = ManagedSessionState::Unavailable;
11277 record.last_error = Some("environment-profile-unavailable".to_owned());
11278 record.updated_at_unix_ms = unix_time_ms();
11279 upserts.push(record.clone());
11280 }
11281 }
11282 }
11283 }
11284 if let Some(new_record_id) = rebind.clone() {
11285 if let Some(binding) = self
11286 .session_bindings
11287 .lock()
11288 .unwrap_or_else(|poisoned| poisoned.into_inner())
11289 .get_mut(&address.session.instance_id)
11290 {
11291 binding.record_id = Some(new_record_id);
11292 }
11293 }
11294 if upserts.is_empty() && removals.is_empty() {
11295 return;
11296 }
11297 if self.persist_state_locked().is_err() {
11298 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11299 let mut records = self
11300 .session_records
11301 .lock()
11302 .unwrap_or_else(|poisoned| poisoned.into_inner());
11303 records.records = original_records;
11304 *self
11305 .materializations
11306 .lock()
11307 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11308 if let Some(current) = records.records.get_mut(record_id) {
11309 current.state = ManagedSessionState::Unavailable;
11310 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11311 current.updated_at_unix_ms = unix_time_ms();
11312 upserts = vec![current.clone()];
11313 } else {
11314 upserts.clear();
11315 }
11316 removals.clear();
11317 drop(records);
11318 if let Some(binding) = self
11319 .session_bindings
11320 .lock()
11321 .unwrap_or_else(|poisoned| poisoned.into_inner())
11322 .get_mut(&address.session.instance_id)
11323 {
11324 binding.record_id = Some(record_id.clone());
11325 }
11326 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11327 }
11328 for record in upserts {
11329 self.publish_record(record);
11330 }
11331 for record_id in removals {
11332 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11333 }
11334 }
11335
11336 fn snapshot(&self) -> NodeSnapshot {
11337 let control = self.handle.snapshot();
11338 let bindings = self
11339 .session_bindings
11340 .lock()
11341 .unwrap_or_else(|poisoned| poisoned.into_inner())
11342 .clone();
11343 let workspace_roots = self
11344 .workspaces
11345 .read()
11346 .unwrap_or_else(|poisoned| poisoned.into_inner())
11347 .clone();
11348 let environment_profiles = self
11349 .environment_profiles
11350 .read()
11351 .unwrap_or_else(|poisoned| poisoned.into_inner());
11352 let mut workspaces = workspace_roots
11353 .iter()
11354 .map(|(workspace_id, canonical_root)| {
11355 (
11356 workspace_id.clone(),
11357 WorkspaceSnapshot {
11358 workspace_id: workspace_id.clone(),
11359 canonical_root: opaque_windows_path(canonical_root.clone()),
11360 sessions: Vec::new(),
11361 worktree_service_mode: Some(
11362 self.worktree_service_modes
11363 .get(workspace_id)
11364 .copied()
11365 .unwrap_or(WorktreeServiceMode::Manual),
11366 ),
11367 managed_worktree_profiles: Some(WorktreeProfileInventory {
11368 profiles: self
11369 .managed_worktree_profiles
11370 .get(workspace_id)
11371 .into_iter()
11372 .flat_map(BTreeMap::values)
11373 .map(|profile| ManagedWorktreeProfileSummary {
11374 id: profile.profile_id().clone(),
11375 revision: profile.revision().clone(),
11376 retention: profile.retention(),
11377 })
11378 .collect(),
11379 }),
11380 },
11381 )
11382 })
11383 .collect::<BTreeMap<_, _>>();
11384 let mut agent_progress = Vec::new();
11385 for session in &control.sessions {
11386 let Some(binding) = bindings.get(&session.instance_id) else {
11387 continue;
11388 };
11389 if binding.generation != session.generation {
11390 continue;
11391 }
11392 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11393 let address = SessionAddress {
11394 workspace_id: binding.workspace_id.clone(),
11395 session: SessionKey {
11396 instance_id: session.instance_id,
11397 generation: session.generation,
11398 },
11399 };
11400 if let Some(progress) =
11401 agent_progress_from_provider_snapshot(address, &session.provider)
11402 {
11403 agent_progress.push(progress);
11404 }
11405 workspace.sessions.push(session.clone());
11406 }
11407 }
11408 NodeSnapshot {
11409 node_id: self.node_id.clone(),
11410 enabled_providers: self.enabled_providers.clone(),
11411 provider_runtime_statuses: {
11412 let updates = self
11413 .provider_runtime_status_updates
11414 .read()
11415 .unwrap_or_else(|poisoned| poisoned.into_inner());
11416 ProviderRuntimeStatuses::new(
11417 self.provider_runtime_statuses
11418 .iter()
11419 .filter(|status| !updates.contains_key(status.provider()))
11420 .cloned()
11421 .chain(updates.values().cloned()),
11422 )
11423 .expect("refreshed provider runtime inventory remains bounded and unique")
11424 },
11425 workspaces: workspaces.into_values().collect(),
11426 session_records: self
11427 .session_records
11428 .lock()
11429 .unwrap_or_else(|poisoned| poisoned.into_inner())
11430 .records
11431 .values()
11432 .cloned()
11433 .collect(),
11434 managed_worktrees: self.managed_worktrees
11435 .lock()
11436 .unwrap_or_else(|poisoned| poisoned.into_inner())
11437 .snapshots(),
11438 launch_inventory: Some(LaunchInventory {
11439 spawn_profiles: Some(
11440 self.spawn_profiles
11441 .iter()
11442 .filter_map(|profile| {
11443 resolved_spawn_profile_summary(profile, &environment_profiles)
11444 })
11445 .collect(),
11446 ),
11447 bundles: Some(
11448 self.bundle_catalog
11449 .read()
11450 .unwrap_or_else(|poisoned| poisoned.into_inner())
11451 .iter()
11452 .map(NodeBundle::receipt)
11453 .collect(),
11454 ),
11455 network_allowlists: {
11458 let catalog = self
11459 .network_allowlist_catalog
11460 .read()
11461 .unwrap_or_else(|poisoned| poisoned.into_inner());
11462 if catalog.is_empty() {
11463 None
11464 } else {
11465 Some(catalog.ids().cloned().collect())
11466 }
11467 },
11468 }),
11469 agent_progress,
11470 }
11471 }
11472
11473 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11474 assert!(
11475 !matches!(
11476 &event,
11477 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11478 ),
11479 "terminal frame and agent stream events must use their own dedicated live channel",
11480 );
11481 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11482 let sequence = history
11483 .last_sequence
11484 .checked_add(1)
11485 .expect("node event sequence exhausted");
11486 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11487 if let Some(evicted) = history.events.pop_front() {
11488 history.replay_floor_sequence = evicted
11489 .sequence
11490 .checked_add(1)
11491 .expect("node replay floor sequence exhausted");
11492 history.removed_record_providers.remove(&evicted.sequence);
11493 }
11494 }
11495 match &event {
11496 NodeEvent::HarnessMcpReadCall { .. } => {
11497 panic!("transient harness MCP calls must not enter durable event history")
11498 }
11499 NodeEvent::SessionRecordUpserted { record } => {
11500 history
11501 .record_providers
11502 .insert(record.record_id.clone(), record.provider.clone());
11503 }
11504 NodeEvent::SessionRecordRemoved { record_id } => {
11505 if let Some(provider) = history.record_providers.remove(record_id) {
11506 history.removed_record_providers.insert(sequence, provider);
11507 }
11508 }
11509 NodeEvent::ManagedWorktreeUpserted { .. }
11510 | NodeEvent::ManagedWorktreeRemoved { .. }
11511 | NodeEvent::Control { .. }
11512 | NodeEvent::ControllerChanged { .. }
11513 | NodeEvent::SessionRecordHistorySummarized { .. }
11514 | NodeEvent::WorkspaceAdded { .. }
11515 | NodeEvent::WorkspaceRemoved { .. }
11516 | NodeEvent::ResyncRequired { .. }
11517 | NodeEvent::AgentStream { .. }
11518 | NodeEvent::TerminalFrame { .. } => {}
11519 }
11520 let envelope = NodeEventEnvelope { sequence, event };
11521 history.events.push_back(envelope.clone());
11522 history.last_sequence = sequence;
11523 let _ = self.event_tx.send(envelope.clone());
11524 drop(history);
11525 envelope
11526 }
11527
11528 fn publish_transient(&self, event: NodeEvent) -> bool {
11539 assert!(event.requires_harness_mcp_proxy_capability());
11540 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11541 let envelope = NodeEventEnvelope { sequence: 0, event };
11542 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11543 Ok(_) => true,
11544 Err(broadcast::error::SendError(undelivered)) => {
11545 if let Some(NodeEventEnvelope {
11546 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11547 ..
11548 }) = undelivered.first() {
11549 tracing::warn!(
11555 call_id = call_id.as_str(),
11556 content_type = ?request.content_type,
11557 cause = "no-subscribers",
11558 "harness MCP proxy event could not be published",
11559 );
11560 }
11561 false
11562 }
11563 }
11564 }
11565
11566 fn publish_terminal_frames(&self) {
11567 let snapshot = self.handle.snapshot();
11568 let bindings = self
11569 .session_bindings
11570 .lock()
11571 .unwrap_or_else(|poisoned| poisoned.into_inner());
11572 let candidates = snapshot
11573 .sessions
11574 .iter()
11575 .filter_map(|session| {
11576 let binding = bindings.get(&session.instance_id)?;
11577 if binding.generation != session.generation {
11578 return None;
11579 }
11580 let frame = session.terminal_frame.clone()?;
11581 crate::session_signals::feed(
11582 binding.workspace_id.as_str(),
11583 session.instance_id.0,
11584 session.agent_id.as_str(),
11585 &frame.contents,
11586 );
11587 crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11588 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_screen(session.instance_id.0, &frame.contents, Instant::now());
11589 if let Some(act) = act {
11590 provider_error_act(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str(), "pty", act);
11591 }
11592 crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11593 Some((
11594 SessionAddress {
11595 workspace_id: binding.workspace_id.clone(),
11596 session: SessionKey {
11597 instance_id: session.instance_id,
11598 generation: session.generation,
11599 },
11600 },
11601 frame,
11602 ))
11603 })
11604 .collect();
11605 drop(bindings);
11606 self.publish_terminal_frame_candidates(candidates);
11607 }
11608
11609 fn publish_terminal_frame_candidates(
11610 &self,
11611 candidates: Vec<(SessionAddress, TerminalFrame)>,
11612 ) {
11613 let mut watermarks = self
11614 .terminal_frame_watermarks
11615 .lock()
11616 .unwrap_or_else(|poisoned| poisoned.into_inner());
11617 let mut events = Vec::with_capacity(candidates.len());
11618 for (address, frame) in candidates {
11619 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11620 let advanced = watermarks
11621 .get(&address.session.instance_id)
11622 .map_or(true, |(current_address, sequence)| {
11623 current_address != &address || frame.sequence > *sequence
11624 });
11625 if !advanced {
11626 continue;
11627 }
11628 if is_first_frame {
11629 if self.controller_state().is_none() {
11643 tracing::warn!(
11644 node_id = %self.node_id,
11645 workspace_id = %address.workspace_id,
11646 session = ?address.session,
11647 terminal_size = ?frame.size,
11648 "session producing output with no controller attached",
11649 );
11650 } else {
11651 tracing::info!(
11652 node_id = %self.node_id,
11653 workspace_id = %address.workspace_id,
11654 session = ?address.session,
11655 terminal_size = ?frame.size,
11656 "session produced its first output",
11657 );
11658 }
11659 }
11660 watermarks.insert(
11661 address.session.instance_id,
11662 (address.clone(), frame.sequence),
11663 );
11664 if let Some((at, reported)) = self
11665 .pty_input_marks
11666 .lock()
11667 .unwrap_or_else(|poisoned| poisoned.into_inner())
11668 .get_mut(&address.session.instance_id)
11669 {
11670 if !*reported && at.elapsed() < Duration::from_secs(5) {
11672 *reported = true;
11673 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");
11674 }
11675 }
11676 events.push(NodeEvent::TerminalFrame { address, frame });
11677 }
11678 drop(watermarks);
11679 if events.is_empty() {
11680 return;
11681 }
11682 let mut history = self
11683 .history
11684 .lock()
11685 .unwrap_or_else(|poisoned| poisoned.into_inner());
11686 let mut envelopes = Vec::with_capacity(events.len());
11687 for event in events {
11688 let sequence = history
11689 .last_sequence
11690 .checked_add(1)
11691 .expect("node event sequence exhausted");
11692 history.last_sequence = sequence;
11693 envelopes.push(NodeEventEnvelope { sequence, event });
11694 }
11695 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11696 drop(history);
11697 }
11698
11699 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11708 let mut history = self
11709 .history
11710 .lock()
11711 .unwrap_or_else(|poisoned| poisoned.into_inner());
11712 let sequence = history
11713 .last_sequence
11714 .checked_add(1)
11715 .expect("node event sequence exhausted");
11716 history.last_sequence = sequence;
11717 drop(history);
11718 tracing::debug!(
11726 source_sequence = chunk.source_sequence,
11727 kind = agent_stream_chunk_kind_label(&chunk.kind),
11728 envelope_sequence = sequence,
11729 "publishing agent stream chunk",
11730 );
11731 let envelope = NodeEventEnvelope {
11732 sequence,
11733 event: NodeEvent::AgentStream { address, chunk },
11734 };
11735 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11736 }
11737
11738 fn current_sequence(&self) -> u64 {
11739 self.history
11740 .lock()
11741 .unwrap_or_else(|poisoned| poisoned.into_inner())
11742 .last_sequence
11743 }
11744
11745 fn controller_state(&self) -> Option<ControllerState> {
11746 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11747 let lease = controller.as_ref().copied()?;
11748 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11749 if remaining.is_zero() {
11750 *controller = None;
11751 return None;
11752 }
11753 Some(ControllerState {
11754 connection_id: lease.connection_id,
11755 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11756 })
11757 }
11758
11759 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11760 if role != ClientRole::Operator {
11761 tracing::warn!(
11762 node_id = %self.node_id,
11763 connection_id,
11764 "controller lease rejected: observer connections cannot acquire control",
11765 );
11766 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11767 }
11768 let lease_ms = if lease_ms == 0 {
11769 DEFAULT_CONTROLLER_LEASE_MS
11770 } else {
11771 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11772 };
11773 let now = Instant::now();
11774 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11775 if let Some(active) = controller.as_ref() {
11776 if active.connection_id != connection_id && active.expires_at > now {
11777 tracing::warn!(
11778 node_id = %self.node_id,
11779 connection_id,
11780 holder_connection_id = active.connection_id,
11781 "controller lease rejected: another operator holds it",
11782 );
11783 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11784 }
11785 }
11786 let is_new_attach = match controller.as_ref() {
11793 Some(active) => active.connection_id != connection_id,
11794 None => true,
11795 };
11796 *controller = Some(ControllerLease {
11797 connection_id,
11798 expires_at: now + Duration::from_millis(lease_ms),
11799 });
11800 drop(controller);
11801 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11802 if is_new_attach {
11803 tracing::info!(
11804 node_id = %self.node_id,
11805 connection_id,
11806 lease_ms,
11807 "controller lease acquired",
11808 );
11809 } else {
11810 tracing::debug!(
11811 node_id = %self.node_id,
11812 connection_id,
11813 lease_ms,
11814 "controller lease renewed",
11815 );
11816 }
11817 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11818 Ok(state)
11819 }
11820
11821 fn release_controller(&self, connection_id: u64) -> bool {
11822 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11823 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11824 if released {
11825 *controller = None;
11826 }
11827 drop(controller);
11828 if released {
11829 tracing::info!(
11830 node_id = %self.node_id,
11831 connection_id,
11832 "controller lease released",
11833 );
11834 self.publish(NodeEvent::ControllerChanged { controller: None });
11835 }
11836 released
11837 }
11838
11839 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11848 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11849 return;
11850 };
11851 let id = address.session.instance_id;
11852 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11853 return;
11854 };
11855 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11856 .iter()
11857 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11858 .collect();
11859 let (writes, set_model, effort_gap) =
11860 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11861 if set_model {
11862 if let Some(model_id) = model.clone() {
11863 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11864 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11865 }
11866 }
11867 }
11868 for (option_id, value_json) in writes {
11869 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11870 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11871 }
11872 }
11873 if effort_gap {
11874 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11875 }
11876 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11877 }
11878
11879 fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11882 let id = address.session.instance_id;
11883 let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11884 if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11885 crate::auto_permit::forget_acp(id.0);
11886 return;
11887 }
11888 let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11889 if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11890 tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11891 }
11892 match provider_event {
11893 ProviderEvent::ModeChanged { mode_id, available } => {
11894 if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11895 match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11896 Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11897 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11898 }
11899 }
11900 }
11901 ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11902 match self.dispatch(ControlCommand::ResolveInteraction {
11903 instance_id: id,
11904 generation: address.session.generation,
11905 interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11906 response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11907 }) {
11908 Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11909 Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11910 }
11911 }
11912 _ => {}
11915 }
11916 }
11917
11918 fn observe_acp_provider_error(&self, address: &SessionAddress, event: &ControlEvent) {
11921 let id = address.session.instance_id.0;
11922 let found = match &event.event {
11923 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11924 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).forget(id);
11925 return;
11926 }
11927 ControlEventKind::ProviderEvent { event: e, .. } => match e {
11928 ProviderEvent::TurnStarted { prompt } => {
11929 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_started(id, prompt.as_deref());
11930 return;
11931 }
11932 ProviderEvent::SessionEnded { is_error: false, .. } => {
11933 self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).turn_ok(id);
11934 return;
11935 }
11936 ProviderEvent::SessionEnded { stop_reason: Some(ProviderStopReason::ProviderError { message, vendor_code, .. }), .. } => crate::provider_error::classify_message(message, vendor_code.as_deref()),
11937 ProviderEvent::Error { message } => crate::provider_error::classify_message(message, None),
11938 _ => return,
11939 },
11940 _ => return,
11941 };
11942 let provider = crate::auto_permit::acp_provider(id).map(|p| p.to_string()).unwrap_or_else(|| "acp".into());
11943 let act = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).observe_acp_error(id, found, Instant::now());
11944 if let Some(act) = act {
11945 provider_error_act(address.workspace_id.as_str(), id, &provider, "acp", act);
11946 }
11947 }
11948
11949 fn run_due_provider_retries(&self) {
11951 let due = self.provider_errors.lock().unwrap_or_else(|p| p.into_inner()).take_due(Instant::now());
11952 for (id, how, attempt) in due {
11953 let instance_id = AgentInstanceId(id);
11954 let r = match how {
11955 crate::provider_error::Retry::Pty => self
11956 .dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalText(TerminalText { text: "continue".to_owned() }) })
11957 .and_then(|_| self.dispatch(ControlCommand::SendInput { instance_id, action: InputAction::TerminalControl(TerminalControl::Enter) })),
11958 crate::provider_error::Retry::Acp { prompt } => self.dispatch(ControlCommand::SendInput {
11959 instance_id,
11960 action: InputAction::SubmitPrompt(PromptPayload { text: prompt, framing: PromptFraming::BracketedPaste }),
11961 }),
11962 };
11963 match r {
11964 Ok(_) => tracing::info!(target: "provider_retry", instance_id = id, attempt, "provider error: turn retried"),
11965 Err(e) => tracing::warn!(target: "provider_retry", instance_id = id, attempt, message = %e.message, "provider error: retry not sent"),
11966 }
11967 }
11968 }
11969
11970 fn mark_spawn_provisional(&self, address: SessionAddress) {
11971 let id = address.session.instance_id;
11972 self.provisional_spawns
11973 .lock()
11974 .unwrap_or_else(|poisoned| poisoned.into_inner())
11975 .insert(id, (address, Instant::now()));
11976 tracing::info!(
11977 node_id = %self.node_id,
11978 instance_id = id.0,
11979 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11980 "draft spawn; waiting for HQ view",
11981 );
11982 }
11983
11984 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11985 let removed = self
11986 .provisional_spawns
11987 .lock()
11988 .unwrap_or_else(|poisoned| poisoned.into_inner())
11989 .remove(&instance_id);
11990 if removed.is_some() {
11991 tracing::info!(
11992 node_id = %self.node_id,
11993 instance_id = instance_id.0,
11994 "draft spawn claimed by HQ view",
11995 );
11996 }
11997 }
11998
11999 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
12000 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
12001 let mut map = self
12002 .provisional_spawns
12003 .lock()
12004 .unwrap_or_else(|poisoned| poisoned.into_inner());
12005 let due: Vec<AgentInstanceId> = map
12006 .iter()
12007 .filter(|(_, (_, at))| at.elapsed() >= grace)
12008 .map(|(id, _)| *id)
12009 .collect();
12010 due.into_iter()
12011 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
12012 .collect()
12013 }
12014
12015 async fn reap_provisional_spawn(&self, address: SessionAddress) {
12016 let instance_id = address.session.instance_id;
12017 tracing::warn!(
12018 node_id = %self.node_id,
12019 instance_id = instance_id.0,
12020 workspace = %address.workspace_id,
12021 reason = "attach_never_arrived",
12022 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
12023 "draft spawn never viewed; stopping PTY",
12024 );
12025 let _ = self.dispatch(ControlCommand::Stop {
12026 instance_id,
12027 force: true,
12028 });
12029 let _ = self.remove_session(&address).await;
12030 }
12031
12032 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
12033 if role != ClientRole::Operator {
12034 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
12035 }
12036 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
12037 let now = Instant::now();
12038 match controller.as_ref().copied() {
12039 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
12040 Some(active) if active.expires_at <= now => {
12041 *controller = None;
12042 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
12043 }
12044 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
12045 }
12046 }
12047
12048 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
12049 if !self
12050 .workspaces
12051 .read()
12052 .unwrap_or_else(|poisoned| poisoned.into_inner())
12053 .contains_key(&address.workspace_id)
12054 {
12055 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
12056 }
12057 let binding = self
12058 .session_bindings
12059 .lock()
12060 .unwrap_or_else(|poisoned| poisoned.into_inner())
12061 .get(&address.session.instance_id)
12062 .cloned()
12063 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
12064 if binding.workspace_id != address.workspace_id {
12065 return Err(failure(
12066 NodeFailureCode::SessionWorkspaceMismatch,
12067 "session belongs to another workspace",
12068 ));
12069 }
12070 if binding.generation != address.session.generation {
12071 return Err(failure(
12072 NodeFailureCode::StaleGeneration,
12073 &format!(
12074 "stale session generation: expected {}, received {}",
12075 binding.generation.0,
12076 address.session.generation.0,
12077 ),
12078 ));
12079 }
12080 let snapshot = self.handle.snapshot();
12081 let Some(current) = snapshot
12082 .sessions
12083 .iter()
12084 .find(|item| item.instance_id == address.session.instance_id)
12085 else {
12086 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12087 };
12088 if current.generation != address.session.generation {
12089 return Err(failure(
12090 NodeFailureCode::StaleGeneration,
12091 &format!(
12092 "stale session generation: expected {}, received {}",
12093 current.generation.0,
12094 address.session.generation.0,
12095 ),
12096 ));
12097 }
12098 Ok(current.agent_id.clone())
12099 }
12100
12101 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12102 self.dispatch_envelope(self.prepare_command(command))
12103 }
12104
12105 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12106 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12107 CommandEnvelope {
12108 id: CommandId(id),
12109 command,
12110 }
12111 }
12112
12113 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12114 let command_id = envelope.id;
12115 self.handle
12116 .dispatch(envelope)
12117 .map_err(|error| match error {
12118 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12119 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12120 })?;
12121 Ok(command_id)
12122 }
12123
12124 fn record_rejected_command(
12133 &self,
12134 command_id: CommandId,
12135 message: String,
12136 unsupported_transport: bool,
12137 ) {
12138 let now = Instant::now();
12139 let mut reasons = self.rejected_command_reasons.lock()
12140 .unwrap_or_else(|poisoned| poisoned.into_inner());
12141 reasons.retain(|_, reason| reason.expires_at > now);
12142 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12143 if let Some(&oldest) = reasons.keys().next() {
12144 reasons.remove(&oldest);
12145 }
12146 }
12147 reasons.insert(
12148 command_id,
12149 RejectedCommandReason {
12150 message,
12151 unsupported_transport,
12152 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12153 },
12154 );
12155 }
12156
12157 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12164 let now = Instant::now();
12165 let mut reasons = self.rejected_command_reasons.lock()
12166 .unwrap_or_else(|poisoned| poisoned.into_inner());
12167 match reasons.remove(&command_id) {
12168 Some(reason) if reason.expires_at > now => Some(reason),
12169 _ => None,
12170 }
12171 }
12172
12173 async fn dispatch_bounded(
12174 &self,
12175 command: ControlCommand,
12176 timeout_duration: Duration,
12177 ) -> Result<CommandId, NodeFailure> {
12178 let deadline = Instant::now() + timeout_duration;
12179 loop {
12180 match self.dispatch(command.clone()) {
12181 Ok(command_id) => return Ok(command_id),
12182 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12183 sleep(Duration::from_millis(2)).await;
12184 }
12185 Err(error) => return Err(error),
12186 }
12187 }
12188 }
12189
12190 fn internal_session_snapshot(
12191 &self,
12192 address: &SessionAddress,
12193 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12194 self.validate_address(address)?;
12195 self.handle
12196 .snapshot()
12197 .sessions
12198 .clone()
12199 .into_iter()
12200 .find(|session| {
12201 session.instance_id == address.session.instance_id
12202 && session.generation == address.session.generation
12203 })
12204 .ok_or_else(|| {
12205 failure(
12206 NodeFailureCode::UnknownSession,
12207 "session snapshot is unavailable",
12208 )
12209 })
12210 }
12211
12212 fn require_acp_session(
12220 &self,
12221 address: &SessionAddress,
12222 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12223 let snapshot = self.internal_session_snapshot(address)?;
12224 if snapshot.transport != TransportKind::Acp {
12225 return Err(failure(
12226 NodeFailureCode::UnsupportedTransport,
12227 "this verb requires a session spawned over the ACP transport",
12228 ));
12229 }
12230 Ok(snapshot)
12231 }
12232
12233 async fn dispatch_history_bounded(
12234 &self,
12235 address: &SessionAddress,
12236 command: ControlCommand,
12237 operation: HistoryOperation,
12238 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12239 self.validate_address(address)?;
12240 let started_after = self.current_sequence();
12241 let command_id = self
12242 .dispatch_bounded(
12243 command,
12244 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12245 )
12246 .await?;
12247 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12248 let mut scan_after = started_after;
12249 let mut accepted_after = None;
12250 loop {
12251 let events = self
12252 .history
12253 .lock()
12254 .unwrap_or_else(|poisoned| poisoned.into_inner())
12255 .events
12256 .iter()
12257 .filter(|envelope| envelope.sequence > scan_after)
12258 .cloned()
12259 .collect::<Vec<_>>();
12260 for envelope in events {
12261 scan_after = scan_after.max(envelope.sequence);
12262 let NodeEvent::Control {
12263 address: event_address,
12264 event,
12265 } = envelope.event
12266 else {
12267 continue;
12268 };
12269 if event_address != *address {
12270 continue;
12271 }
12272 if event.command_id == Some(command_id) {
12273 match &event.event {
12274 ControlEventKind::CommandRejected { .. } => {
12275 return Err(failure(
12276 NodeFailureCode::InvalidRequest,
12277 "history request was rejected",
12278 ));
12279 }
12280 ControlEventKind::HistoryRequested {
12281 operation: requested,
12282 ..
12283 } if requested == &operation => {
12284 accepted_after = Some(envelope.sequence);
12285 continue;
12286 }
12287 _ => {}
12288 }
12289 }
12290 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12291 match (&operation, &event.event) {
12292 (
12293 HistoryOperation::Discover { .. },
12294 ControlEventKind::HistoryDiscovered { .. },
12295 )
12296 | (
12297 HistoryOperation::Load { .. },
12298 ControlEventKind::HistoryLoaded { .. },
12299 ) => return self.internal_session_snapshot(address),
12300 (_, ControlEventKind::HistoryFailed { .. }) => {
12301 return Err(failure(
12302 NodeFailureCode::BackendOperationFailed,
12303 "history operation failed",
12304 ));
12305 }
12306 _ => {}
12307 }
12308 }
12309 }
12310 if Instant::now() >= deadline {
12311 return Err(failure(
12312 NodeFailureCode::BackendBusy,
12313 if accepted_after.is_some() {
12314 "history operation did not settle before the bounded deadline"
12315 } else {
12316 "history operation was not accepted before the bounded deadline"
12317 },
12318 ));
12319 }
12320 sleep(Duration::from_millis(2)).await;
12321 }
12322 }
12323
12324 async fn discover_history(
12325 &self,
12326 address: &SessionAddress,
12327 limit: u16,
12328 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12329 let query = HistoryQuery {
12330 working_directory: Some(self.workspace_root(&address.workspace_id)?),
12331 limit,
12332 };
12333 query
12334 .validate()
12335 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12336 let snapshot = self
12337 .dispatch_history_bounded(
12338 address,
12339 ControlCommand::DiscoverHistory {
12340 instance_id: address.session.instance_id,
12341 query: query.clone(),
12342 },
12343 HistoryOperation::Discover { query },
12344 )
12345 .await?;
12346 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12347 return Err(failure(
12348 NodeFailureCode::BackendOperationFailed,
12349 "history discovery settled into an invalid state",
12350 ));
12351 }
12352 Ok(snapshot.history.candidates)
12353 }
12354
12355 async fn load_history(
12356 &self,
12357 address: &SessionAddress,
12358 candidate_id: String,
12359 ) -> Result<HistorySessionRecord, NodeFailure> {
12360 validate_candidate_id(&candidate_id)
12361 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12362 let snapshot = self
12363 .dispatch_history_bounded(
12364 address,
12365 ControlCommand::LoadHistory {
12366 instance_id: address.session.instance_id,
12367 candidate_id: candidate_id.clone(),
12368 },
12369 HistoryOperation::Load {
12370 candidate_id: candidate_id.clone(),
12371 },
12372 )
12373 .await?;
12374 if snapshot.history.pending.is_some()
12375 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12376 {
12377 return Err(failure(
12378 NodeFailureCode::BackendOperationFailed,
12379 "history load settled into an invalid state",
12380 ));
12381 }
12382 snapshot.history.loaded.ok_or_else(|| {
12383 failure(
12384 NodeFailureCode::BackendOperationFailed,
12385 "history load produced no bounded session",
12386 )
12387 })
12388 }
12389
12390 async fn export_context_pack_for_session_record(
12397 &self,
12398 record_id: &SessionRecordId,
12399 session: &SessionAddress,
12400 allow_clean_detachment: bool,
12401 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12402 let outcome = self
12408 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12409 .await;
12410 if let Err(failure) = &outcome {
12411 tracing::warn!(
12412 record = %record_id,
12413 code = ?failure.code,
12414 reason = %failure.message,
12415 "session-record context-pack export refused",
12416 );
12417 }
12418 outcome
12419 }
12420
12421 async fn export_context_pack_for_session_record_inner(
12422 &self,
12423 record_id: &SessionRecordId,
12424 session: &SessionAddress,
12425 allow_clean_detachment: bool,
12426 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12427 let step = |name: &'static str| move |failure: NodeFailure| {
12432 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12433 failure
12434 };
12435 let record = self.record(record_id).map_err(step("record-lookup"))?;
12436 let identity = match record.provider_session.clone() {
12452 Some(identity) => {
12453 if identity.key != ProviderSessionKey::SessionId {
12454 return Err(step("provider-identity-key")(failure(
12455 NodeFailureCode::BackendOperationFailed,
12456 "managed session provider identity cannot bind history exactly",
12457 )));
12458 }
12459 Some(identity)
12460 }
12461 None if record.state == ManagedSessionState::IdentityPending => {
12462 return Err(step("provider-session-identity")(failure(
12463 NodeFailureCode::BackendOperationFailed,
12464 "managed session has not yet observed its provider session identity",
12465 )));
12466 }
12467 None => None,
12468 };
12469 self.revalidate_session_record_context_export(
12470 &record,
12471 identity.as_ref(),
12472 session,
12473 allow_clean_detachment,
12474 ).map_err(step("revalidate-before-discovery"))?;
12475 let candidates = self
12476 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12477 .await
12478 .map_err(step("discover-history"))?;
12479 let candidate_id = select_history_candidate(
12484 &candidates,
12485 identity.as_ref().map(|identity| identity.id.as_str()),
12486 ).map_err(step("unique-candidate"))?;
12487 let loaded = self.load_history(session, candidate_id.clone()).await
12488 .map_err(step("load-history"))?;
12489 if let Some(identity) = identity.as_ref() {
12490 if loaded.session_id != identity.id {
12491 return Err(failure(
12492 NodeFailureCode::SessionRecordConflict,
12493 "loaded history identity does not match the managed session record",
12494 ));
12495 }
12496 }
12497 self.revalidate_session_record_context_export(
12498 &record,
12499 identity.as_ref(),
12500 session,
12501 allow_clean_detachment,
12502 )?;
12503 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12504 .map_err(step("revalidate-loaded-history"))?;
12505 let pack = self.materialize_context_pack(session).await
12506 .map_err(step("materialize-context-pack"))?;
12507 self.commit_context_pack_for_session_record(
12508 &record,
12509 identity.as_ref(),
12510 session,
12511 &candidate_id,
12512 &loaded,
12513 pack,
12514 allow_clean_detachment,
12515 )
12516 }
12517
12518 async fn export_context_pack(
12519 &self,
12520 address: &SessionAddress,
12521 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12522 let pack = self.materialize_context_pack(address).await?;
12523 self.commit_context_pack(pack)
12524 }
12525
12526 async fn materialize_context_pack(
12527 &self,
12528 address: &SessionAddress,
12529 ) -> Result<NodeContextPack, NodeFailure> {
12530 let snapshot = self.internal_session_snapshot(address)?;
12531 if !context_pack_source_status_is_usable(&snapshot.status) {
12532 return Err(failure(
12533 NodeFailureCode::InvalidRequest,
12534 "context export requires a running or successfully exited source session",
12535 ));
12536 }
12537 if snapshot.history.pending.is_some()
12538 || snapshot.history.loaded_candidate_id.is_none()
12539 {
12540 return Err(failure(
12541 NodeFailureCode::InvalidRequest,
12542 "context export requires settled loaded history",
12543 ));
12544 }
12545 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12546 failure(
12547 NodeFailureCode::InvalidRequest,
12548 "context export requires settled loaded history",
12549 )
12550 })?;
12551 let source_provider = snapshot.agent_id.clone();
12552 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12553 let repository = self
12554 .context_pack_repository(&address.workspace_id)
12555 .await?;
12556 let current = self.internal_session_snapshot(address)?;
12557 if !context_pack_source_status_is_usable(¤t.status)
12558 || current.agent_id != source_provider
12559 || current.history.pending.is_some()
12560 || current.history.loaded_candidate_id != loaded_candidate_id
12561 || current.history.loaded.as_ref() != Some(&loaded)
12562 {
12563 return Err(failure(
12564 NodeFailureCode::ContextPackMaterializationFailed,
12565 "context source changed during bounded repository capture",
12566 ));
12567 }
12568 NodeContextPack::export_with_repository(
12569 ContextPackLineageReceipt {
12570 source_node_id: self.node_id.clone(),
12571 source_session: address.clone(),
12572 source_provider,
12573 },
12574 &loaded,
12575 Some(repository),
12576 )
12577 .map_err(|_| {
12578 failure(
12579 NodeFailureCode::ContextPackMaterializationFailed,
12580 "bounded context pack export failed",
12581 )
12582 })
12583 }
12584
12585 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12591 let mut store = self
12592 .context_pack_store
12593 .lock()
12594 .unwrap_or_else(|poisoned| poisoned.into_inner());
12595 let Some(store) = store.as_mut() else {
12596 return Ok(());
12597 };
12598 store.commit(pack).map_err(|_| {
12599 failure(
12600 NodeFailureCode::ContextPackMaterializationFailed,
12601 "durable context pack commit failed",
12602 )
12603 })
12604 }
12605
12606 fn commit_context_pack(
12607 &self,
12608 pack: NodeContextPack,
12609 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12610 self.commit_context_pack_durably(&pack)?;
12611 self.context_catalog
12612 .write()
12613 .unwrap_or_else(|poisoned| poisoned.into_inner())
12614 .insert(pack)
12615 .map_err(|_| {
12616 failure(
12617 NodeFailureCode::ContextPackMaterializationFailed,
12618 "context pack catalog is full",
12619 )
12620 })
12621 }
12622
12623 fn commit_context_pack_for_session_record(
12624 &self,
12625 expected: &ManagedSessionRecord,
12626 identity: Option<&ProviderSessionIdentity>,
12627 session: &SessionAddress,
12628 candidate_id: &str,
12629 loaded: &HistorySessionRecord,
12630 pack: NodeContextPack,
12631 allow_clean_detachment: bool,
12632 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12633 let current_root = self.workspace_root(&expected.workspace_id)?;
12634 let _transaction = self
12635 .state_transaction
12636 .lock()
12637 .unwrap_or_else(|poisoned| poisoned.into_inner());
12638 let records = self
12639 .session_records
12640 .lock()
12641 .unwrap_or_else(|poisoned| poisoned.into_inner());
12642 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12643 failure(
12644 NodeFailureCode::SessionRecordConflict,
12645 "managed session record disappeared during context export",
12646 )
12647 })?;
12648 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12649 return Err(failure(
12650 NodeFailureCode::SessionRecordConflict,
12651 "managed session record changed during context export",
12652 ));
12653 }
12654 let bindings = self
12655 .session_bindings
12656 .lock()
12657 .unwrap_or_else(|poisoned| poisoned.into_inner());
12658 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12659 failure(
12660 NodeFailureCode::SessionRecordConflict,
12661 "managed session runtime binding disappeared during context export",
12662 )
12663 })?;
12664 let runtime = self
12665 .handle
12666 .snapshot()
12667 .sessions
12668 .iter()
12669 .find(|snapshot| {
12670 snapshot.instance_id == session.session.instance_id
12671 && snapshot.generation == session.session.generation
12672 })
12673 .cloned()
12674 .ok_or_else(|| {
12675 failure(
12676 NodeFailureCode::SessionRecordConflict,
12677 "managed session runtime disappeared during context export",
12678 )
12679 })?;
12680 let receipt = pack.receipt();
12681 if !session_record_context_export_binding_is_exact(
12682 expected,
12683 current,
12684 identity,
12685 session,
12686 binding,
12687 &runtime.agent_id,
12688 ¤t_root,
12689 allow_clean_detachment,
12690 ) || !session_record_context_export_source_is_usable(
12691 current,
12692 &runtime.status,
12693 allow_clean_detachment,
12694 ) || runtime.history.pending.is_some()
12695 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12696 || runtime.history.loaded.as_ref() != Some(loaded)
12697 || receipt.lineage.source_node_id != self.node_id
12698 || receipt.lineage.source_session != *session
12699 || receipt.lineage.source_provider != current.provider
12700 || receipt.source_message_count != loaded.message_count
12701 {
12702 return Err(failure(
12703 NodeFailureCode::SessionRecordConflict,
12704 "managed session source changed before context catalog commit",
12705 ));
12706 }
12707 self.commit_context_pack_durably(&pack)?;
12708 self.context_catalog
12709 .write()
12710 .unwrap_or_else(|poisoned| poisoned.into_inner())
12711 .insert(pack)
12712 .map_err(|_| {
12713 failure(
12714 NodeFailureCode::ContextPackMaterializationFailed,
12715 "context pack catalog is full",
12716 )
12717 })
12718 }
12719
12720 async fn context_pack_repository(
12721 &self,
12722 workspace_id: &WorkspaceId,
12723 ) -> Result<ContextPackRepository, NodeFailure> {
12724 let first = self
12725 .context_pack_repository_observation(workspace_id)
12726 .await?;
12727 let second = self
12728 .context_pack_repository_observation(workspace_id)
12729 .await?;
12730 stable_context_pack_repository(first, second)
12731 }
12732
12733 async fn context_pack_repository_observation(
12734 &self,
12735 workspace_id: &WorkspaceId,
12736 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12737 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12738 let head = if git.is_repository {
12739 let root = self.workspace_root(workspace_id)?;
12740 match resolve_base_commit_with_timeout(
12741 &root,
12742 "HEAD",
12743 GIT_COMMAND_TIMEOUT_MS,
12744 )
12745 .await
12746 {
12747 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12748 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12749 ContextPackRepositoryHead::Unborn
12750 }
12751 Err(_) => {
12752 return Err(failure(
12753 NodeFailureCode::ContextPackMaterializationFailed,
12754 "context pack HEAD observation failed",
12755 ));
12756 }
12757 }
12758 } else {
12759 ContextPackRepositoryHead::NotRepository
12760 };
12761 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12762 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12763 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12764 failure(
12765 NodeFailureCode::InvalidRepositoryPath,
12766 "context pack selected file path is invalid",
12767 )
12768 })?;
12769 match self.read_workspace_file(workspace_id.clone(), path).await {
12770 Ok(WorkspaceFileRead {
12771 content: WorkspaceFileContent::Utf8 { text, byte_len },
12772 ..
12773 }) => files.push(ContextPackRepositoryFileSource::utf8(
12774 selected_path,
12775 text,
12776 byte_len,
12777 )),
12778 Ok(WorkspaceFileRead {
12779 content: WorkspaceFileContent::NonUtf8 { byte_len },
12780 ..
12781 }) => files.push(ContextPackRepositoryFileSource::skipped(
12782 selected_path,
12783 ContextPackSelectedFileSkipReason::NonUtf8,
12784 Some(byte_len),
12785 )),
12786 Ok(WorkspaceFileRead {
12787 content: WorkspaceFileContent::TooLarge { .. },
12788 ..
12789 }) => files.push(ContextPackRepositoryFileSource::skipped(
12790 selected_path,
12791 ContextPackSelectedFileSkipReason::TooLarge,
12792 None,
12793 )),
12794 Err(error) => {
12795 let reason = if matches!(
12796 error.code,
12797 NodeFailureCode::InvalidRepositoryPath
12798 | NodeFailureCode::RepositoryFileNotRegular
12799 | NodeFailureCode::RepositoryPathUnsafe
12800 ) {
12801 ContextPackSelectedFileSkipReason::Unsafe
12802 } else {
12803 ContextPackSelectedFileSkipReason::Unavailable
12804 };
12805 files.push(ContextPackRepositoryFileSource::skipped(
12806 selected_path,
12807 reason,
12808 None,
12809 ));
12810 }
12811 }
12812 }
12813 Ok(ContextPackRepositoryObservation { head, git, files })
12814 }
12815
12816 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12817 let referenced_by_binding = self
12818 .session_bindings
12819 .lock()
12820 .unwrap_or_else(|poisoned| poisoned.into_inner())
12821 .values()
12822 .any(|binding| {
12823 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12824 });
12825 let referenced_by_record = self
12826 .session_records
12827 .lock()
12828 .unwrap_or_else(|poisoned| poisoned.into_inner())
12829 .records
12830 .values()
12831 .any(|record| record.context_id.as_ref() == Some(context_id));
12832 let referenced_by_materialization = self
12833 .materializations
12834 .lock()
12835 .unwrap_or_else(|poisoned| poisoned.into_inner())
12836 .values()
12837 .any(|ownership| {
12838 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12839 });
12840 if referenced_by_binding
12841 || referenced_by_record
12842 || referenced_by_materialization
12843 {
12844 return Err(failure(
12845 NodeFailureCode::ContextPackBusy,
12846 "context pack is still referenced",
12847 ));
12848 }
12849 let mut catalog = self.context_catalog
12850 .write()
12851 .unwrap_or_else(|poisoned| poisoned.into_inner());
12852 if catalog.get(context_id).is_none() {
12853 return Err(failure(
12854 NodeFailureCode::UnknownContextPack,
12855 "context pack is unavailable",
12856 ));
12857 }
12858 self.spawn_idempotency
12859 .lock()
12860 .unwrap_or_else(|poisoned| poisoned.into_inner())
12861 .entries
12862 .retain(|_, entry| !entry.value.references_context(context_id));
12863 let removed = catalog.remove(context_id);
12864 debug_assert!(removed.is_some(), "validated context pack remains present");
12865 Ok(())
12866 }
12867
12868 async fn dispatch_input_bounded(
12869 &self,
12870 address: &SessionAddress,
12871 action: InputAction,
12872 ) -> Result<(), NodeFailure> {
12873 let started_after = self.current_sequence();
12874 self.pty_input_marks
12875 .lock()
12876 .unwrap_or_else(|poisoned| poisoned.into_inner())
12877 .insert(address.session.instance_id, (Instant::now(), false));
12878 self.pty_input_wake.notify_one();
12879 let command_id = self
12880 .dispatch_bounded(
12881 ControlCommand::SendInput {
12882 instance_id: address.session.instance_id,
12883 action,
12884 },
12885 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12886 )
12887 .await?;
12888 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12892 let mut scan_after = started_after;
12893 let mut accepted_after = None;
12894 loop {
12895 let events = self
12896 .history
12897 .lock()
12898 .unwrap_or_else(|poisoned| poisoned.into_inner())
12899 .events
12900 .iter()
12901 .filter(|envelope| envelope.sequence > scan_after)
12902 .cloned()
12903 .collect::<Vec<_>>();
12904 for envelope in events {
12905 scan_after = scan_after.max(envelope.sequence);
12906 let NodeEvent::Control {
12907 address: event_address,
12908 event,
12909 } = envelope.event
12910 else {
12911 continue;
12912 };
12913 if event_address != *address {
12914 continue;
12915 }
12916 if event.command_id == Some(command_id) {
12917 match &event.event {
12918 ControlEventKind::CommandRejected { message } => {
12919 return Err(failure(NodeFailureCode::InvalidRequest, message));
12920 }
12921 ControlEventKind::InputRequested { .. } => {
12922 accepted_after = Some(envelope.sequence);
12923 continue;
12924 }
12925 _ => {}
12926 }
12927 }
12928 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12929 match &event.event {
12930 ControlEventKind::InputCompleted { .. } => return Ok(()),
12931 ControlEventKind::InputFailed { message, .. } => {
12932 return Err(failure(
12933 NodeFailureCode::BackendOperationFailed,
12934 message,
12935 ));
12936 }
12937 _ => {}
12938 }
12939 }
12940 }
12941 if Instant::now() >= deadline {
12942 return Err(failure(
12943 NodeFailureCode::BackendBusy,
12944 if accepted_after.is_some() {
12945 "input was accepted but did not settle before the bounded deadline"
12946 } else {
12947 "input was not accepted before the bounded deadline"
12948 },
12949 ));
12950 }
12951 sleep(Duration::from_millis(2)).await;
12952 }
12953 }
12954
12955 async fn dispatch_resize_bounded(
12956 &self,
12957 address: &SessionAddress,
12958 size: gate4agent_types::TerminalSize,
12959 ) -> Result<(), NodeFailure> {
12960 let started_after = self.current_sequence();
12961 let command_id = self
12962 .dispatch_bounded(
12963 ControlCommand::Resize {
12964 instance_id: address.session.instance_id,
12965 size,
12966 },
12967 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12968 )
12969 .await?;
12970 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12971 let mut scan_after = started_after;
12972 let mut accepted_after = None;
12973 loop {
12974 let events = self
12975 .history
12976 .lock()
12977 .unwrap_or_else(|poisoned| poisoned.into_inner())
12978 .events
12979 .iter()
12980 .filter(|envelope| envelope.sequence > scan_after)
12981 .cloned()
12982 .collect::<Vec<_>>();
12983 for envelope in events {
12984 scan_after = scan_after.max(envelope.sequence);
12985 let NodeEvent::Control {
12986 address: event_address,
12987 event,
12988 } = envelope.event
12989 else {
12990 continue;
12991 };
12992 if event_address != *address {
12993 continue;
12994 }
12995 if event.command_id == Some(command_id) {
12996 match &event.event {
12997 ControlEventKind::CommandRejected { message } => {
12998 return Err(failure(NodeFailureCode::InvalidRequest, message));
12999 }
13000 ControlEventKind::ResizeRequested {
13001 size: requested_size,
13002 ..
13003 } if *requested_size == size => {
13004 accepted_after = Some(envelope.sequence);
13005 continue;
13006 }
13007 _ => {}
13008 }
13009 }
13010 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
13011 match &event.event {
13012 ControlEventKind::Resized { size: settled_size }
13013 if *settled_size == size =>
13014 {
13015 return Ok(());
13016 }
13017 ControlEventKind::ResizeFailed { message } => {
13018 return Err(failure(
13019 NodeFailureCode::BackendOperationFailed,
13020 message,
13021 ));
13022 }
13023 _ => {}
13024 }
13025 }
13026 }
13027 if Instant::now() >= deadline {
13028 return Err(failure(
13029 NodeFailureCode::BackendBusy,
13030 if accepted_after.is_some() {
13031 "resize was accepted but did not settle before the bounded deadline"
13032 } else {
13033 "resize was not accepted before the bounded deadline"
13034 },
13035 ));
13036 }
13037 sleep(Duration::from_millis(2)).await;
13038 }
13039 }
13040
13041 async fn spawn_session(
13042 &self,
13043 workspace_id: WorkspaceId,
13044 provider: AgentId,
13045 mode: SessionMode,
13046 terminal_size: gate4agent_types::TerminalSize,
13047 initial_prompt: Option<String>,
13048 ) -> Result<SessionAddress, NodeFailure> {
13049 if self.managed_worktrees.lock()
13050 .unwrap_or_else(|poisoned| poisoned.into_inner())
13051 .lease_for_workspace(&workspace_id)
13052 .is_some()
13053 {
13054 return Err(failure(
13055 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13056 "managed worktree targets may only be spawned through their lease request",
13057 ));
13058 }
13059 self
13060 .spawn_session_with_deadline(
13061 workspace_id,
13062 provider.clone(),
13063 mode,
13064 terminal_size,
13065 initial_prompt,
13066 None,
13067 None,
13068 None,
13069 None,
13070 None,
13071 SpawnRecordPolicy::ProviderIdentityOnly,
13072 None,
13073 &[],
13074 None,
13075 crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13082 None,
13083 None,
13084 )
13085 .await
13086 .map(|(session, _runtime_policy)| session)
13087 }
13088
13089 async fn spawn_session_with_deadline(
13090 &self,
13091 workspace_id: WorkspaceId,
13092 provider: AgentId,
13093 mode: SessionMode,
13094 terminal_size: gate4agent_types::TerminalSize,
13095 initial_prompt: Option<String>,
13096 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13097 bundle: Option<ResolvedBundleReceipt>,
13098 context: Option<ResolvedContextPackReceipt>,
13099 managed_authority: Option<ManagedWorktreeLeaseId>,
13100 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13101 record_policy: SpawnRecordPolicy,
13102 deadline: Option<Instant>,
13103 required_capabilities: &[ProviderRuntimeCapability],
13104 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13105 approval_level: ApprovalLevel,
13106 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13107 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13113 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13114 let reserved_lease = self.managed_worktrees.lock()
13115 .unwrap_or_else(|poisoned| poisoned.into_inner())
13116 .lease_for_workspace(&workspace_id);
13117 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13118 (Some(expected), Some(provided)) if expected == provided => {}
13119 (None, None) => {}
13120 _ => {
13121 return Err(failure(
13122 NodeFailureCode::ManagedWorktreeOwnershipConflict,
13123 "spawn lacks exact managed worktree lease authority",
13124 ));
13125 }
13126 }
13127 self.ensure_workspace_environment_profile_compatible(
13131 &workspace_id,
13132 environment_profile.as_ref(),
13133 )?;
13134 let runtime_requirement = match (mode, initial_prompt.is_some()) {
13135 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13136 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13137 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13138 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13139 };
13140 let transport = match mode {
13146 SessionMode::Pty => TransportKind::Pty,
13147 SessionMode::Inline => TransportKind::Pipe,
13148 SessionMode::Acp => TransportKind::Acp,
13149 };
13150 let pipe_transport = self.provider_supports_pipe_transport(&provider);
13151 let acp_transport = self.provider_supports_acp_transport(&provider);
13152 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13153 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13154 NodeFailureCode::UnsupportedSpawnCapability,
13155 "pre-admitted provider runtime no longer satisfies spawn requirements",
13156 ))?;
13157 runtime_policy
13158 } else if let Some(deadline) = deadline {
13159 let remaining = spawn_deadline_remaining(deadline)?;
13160 timeout(
13161 remaining,
13162 self.admit_provider_runtime(&provider, runtime_requirement),
13163 )
13164 .await
13165 .map_err(|_| {
13166 failure(
13167 NodeFailureCode::SpawnDeadlineExceeded,
13168 "provider admission exceeded the spawn deadline",
13169 )
13170 })??
13171 } else {
13172 self.admit_provider_runtime(&provider, runtime_requirement).await?
13173 };
13174 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13175 let runtime_policy =
13187 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13188 if required_capabilities
13189 .iter()
13190 .any(|capability| !runtime_policy.admits(*capability))
13191 {
13192 return Err(failure(
13193 NodeFailureCode::UnsupportedSpawnCapability,
13194 "provider runtime does not admit a required spawn capability",
13195 ));
13196 }
13197 if let Some(deadline) = deadline {
13198 spawn_deadline_remaining(deadline)?;
13199 }
13200 self.ensure_binding_capacity()?;
13201 let mut working_directory = self.workspace_root(&workspace_id)?;
13202 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13203 let session = SessionKey {
13204 instance_id,
13205 generation: SessionGeneration(1),
13206 };
13207 let address = SessionAddress {
13208 workspace_id: workspace_id.clone(),
13209 session,
13210 };
13211 tracing::info!(
13219 node_id = %self.node_id,
13220 workspace_id = %workspace_id,
13221 provider = %provider,
13222 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13223 session = ?address.session,
13224 "session spawn request accepted",
13225 );
13226 let mut prepared_spawn = SpawnPreparer::prepare(
13227 self,
13228 SpawnPrepareRequest {
13229 instance_id,
13230 address: &address,
13231 provider: &provider,
13232 mode,
13233 environment_profile: environment_profile.as_ref(),
13234 bundle: bundle.as_ref(),
13235 context: context.as_ref(),
13236 managed_authority: managed_authority.as_ref(),
13237 harness_mcp,
13238 approval_level,
13239 network_allowlist: network_allowlist.as_ref(),
13240 browser_profile_id,
13241 },
13242 )?;
13243 let materialization_id = prepared_spawn.materialization_id.take();
13244 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13245 let record_id = self.bind_spawn_session_with_materialization(
13246 &address,
13247 provider.clone(),
13248 mode,
13249 runtime_policy,
13250 record_policy,
13251 environment_profile,
13252 bundle,
13253 context,
13254 materialization_id,
13255 )?;
13256 prepared_spawn.retain_session_guards();
13257 let session_provider = provider.clone();
13261 let agent_id = provider;
13262 let register_command_id = match self.dispatch_bounded(
13263 ControlCommand::Register {
13264 instance_id,
13265 agent_id,
13266 transport,
13267 },
13268 dispatch_timeout,
13269 )
13270 .await {
13271 Ok(command_id) => command_id,
13272 Err(error) => {
13273 if let Some(binding) = self.remove_binding(&address) {
13274 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13275 }
13276 if let Some(record_id) = record_id.as_ref() {
13277 self.discard_record(record_id)?;
13278 }
13279 return Err(spawn_dispatch_error(error, deadline));
13280 }
13281 };
13282 let start_timeout = match spawn_dispatch_timeout(deadline) {
13283 Ok(timeout) => timeout,
13284 Err(error) => {
13285 let recovery = self
13286 .rollback_spawn(
13287 &address,
13288 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13289 )
13290 .await;
13291 return match recovery {
13292 Ok(()) => {
13293 if let Some(record_id) = record_id.as_ref() {
13294 self.discard_record(record_id)?;
13295 }
13296 Err(error)
13297 }
13298 Err(recovery_error) => {
13299 if let Some(record_id) = record_id.as_ref() {
13300 self.mark_record_error(
13301 record_id,
13302 "spawn deadline elapsed and registration recovery failed",
13303 )?;
13304 }
13305 Err(recovery_error)
13306 }
13307 };
13308 }
13309 };
13310 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13311 if let Err(recovery_error) = self
13312 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13313 .await
13314 {
13315 return Err(recovery_error);
13316 }
13317 if let Some(record_id) = record_id.as_ref() {
13318 self.discard_record(record_id)?;
13319 }
13320 return Err(start_error);
13321 }
13322 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13324 working_directory = dir.to_string_lossy().into_owned();
13325 }
13326 let start_result = self
13327 .dispatch_bounded(
13328 ControlCommand::Start {
13329 instance_id,
13330 runtime_policy,
13331 request: StartRequest {
13332 working_directory,
13333 terminal_size,
13334 initial_prompt,
13335 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13336 approval_level,
13337 },
13338 },
13339 start_timeout,
13340 )
13341 .await;
13342 let start_command_id = match start_result {
13343 Ok(command_id) => command_id,
13344 Err(start_error) => {
13345 let start_error = spawn_dispatch_error(start_error, deadline);
13346 let recovery = self
13347 .rollback_spawn(
13348 &address,
13349 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13350 )
13351 .await;
13352 return match recovery {
13353 Ok(()) => {
13354 if let Some(record_id) = record_id.as_ref() {
13355 self.discard_record(record_id)?;
13356 }
13357 Err(start_error)
13358 }
13359 Err(recovery_error) => {
13360 let diagnostic = format!(
13361 "start failed and registration recovery failed: {}",
13362 recovery_error.message,
13363 );
13364 if let Some(record_id) = record_id.as_ref() {
13365 self.mark_record_error(record_id, &diagnostic)?;
13366 }
13367 Err(failure(recovery_error.code, &diagnostic))
13368 }
13369 };
13370 }
13371 };
13372
13373 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13374 loop {
13375 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13384 .or_else(|| self.take_rejected_command_reason(start_command_id))
13385 {
13386 self.rollback_spawn(
13387 &address,
13388 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13389 ).await.map_err(|error| {
13390 failure(
13391 error.code,
13392 &format!(
13393 "spawn was rejected ({}) and registration recovery failed: {}",
13394 reason.message, error.message
13395 ),
13396 )
13397 })?;
13398 if let Some(record_id) = record_id.as_ref() {
13399 self.discard_record(record_id)?;
13400 }
13401 let code = if reason.unsupported_transport {
13402 NodeFailureCode::UnsupportedTransport
13403 } else {
13404 NodeFailureCode::BackendOperationFailed
13405 };
13406 return Err(failure(code, &reason.message));
13407 }
13408 if let Some(current) = self
13409 .handle
13410 .snapshot()
13411 .sessions
13412 .iter()
13413 .find(|current| current.instance_id == instance_id)
13414 {
13415 if current.generation != session.generation {
13416 let divergence = failure(
13417 NodeFailureCode::StaleGeneration,
13418 "spawn generation diverged",
13419 );
13420 return match self
13421 .rollback_spawn(
13422 &address,
13423 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13424 )
13425 .await
13426 {
13427 Ok(()) => {
13428 if let Some(record_id) = record_id.as_ref() {
13429 self.discard_record(record_id)?;
13430 }
13431 Err(divergence)
13432 }
13433 Err(recovery_error) => {
13434 if let Some(record_id) = record_id.as_ref() {
13435 self.mark_record_error(
13436 record_id,
13437 "spawn generation diverged and recovery failed",
13438 )?;
13439 }
13440 Err(recovery_error)
13441 }
13442 };
13443 }
13444 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13445 prepared_spawn.retain_launch_guards();
13446 return Ok((address, runtime_policy));
13447 }
13448 }
13449 if Instant::now() >= commit_deadline {
13450 self.rollback_spawn(
13451 &address,
13452 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13453 ).await.map_err(|error| {
13454 failure(
13455 error.code,
13456 &format!(
13457 "start did not commit and registration recovery failed: {}",
13458 error.message
13459 ),
13460 )
13461 })?;
13462 if let Some(record_id) = record_id.as_ref() {
13463 self.discard_record(record_id)?;
13464 }
13465 return Err(failure(
13466 if deadline.is_some() {
13467 NodeFailureCode::SpawnDeadlineExceeded
13468 } else {
13469 NodeFailureCode::BackendBusy
13470 },
13471 "start did not commit before the bounded deadline; registration was removed",
13472 ));
13473 }
13474 sleep(Duration::from_millis(2)).await;
13475 }
13476 }
13477
13478 fn effective_spawn_runtime_policy(
13479 &self,
13480 runtime_policy: ProviderRuntimePolicy,
13481 ) -> ProviderRuntimePolicy {
13482 #[cfg(feature = "fixture")]
13483 let runtime_policy = if self.fixture_semantic_hook_policy {
13484 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13485 .expect("monitoring Hook fixture policy is internally valid")
13486 } else {
13487 runtime_policy
13488 };
13489 runtime_policy
13490 }
13491
13492 fn verify_harness_mcp_before_start(
13493 &self,
13494 prepared: Option<&PreparedHarnessMcpSpawn>,
13495 ) -> Result<(), NodeFailure> {
13496 prepared
13497 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13498 .transpose()
13499 .map(|_| ())
13500 .map_err(harness_mcp_failure)
13501 }
13502
13503 async fn rollback_spawn(
13504 &self,
13505 address: &SessionAddress,
13506 commit_timeout: Duration,
13507 ) -> Result<(), NodeFailure> {
13508 self.dispatch_bounded(
13509 ControlCommand::Remove {
13510 instance_id: address.session.instance_id,
13511 },
13512 commit_timeout,
13513 )
13514 .await?;
13515 self.wait_until_removed(address, commit_timeout).await?;
13516 if let Some(binding) = self.remove_binding(address) {
13517 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13518 }
13519 Ok(())
13520 }
13521
13522 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13523 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13524 self.dispatch_bounded(
13525 ControlCommand::Remove {
13526 instance_id: address.session.instance_id,
13527 },
13528 commit_timeout,
13529 )
13530 .await?;
13531 self.wait_until_removed(address, commit_timeout).await?;
13532 let removed = self.remove_binding(address);
13533 if let Some(binding) = removed.as_ref() {
13534 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13535 }
13536 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13537 let snapshot = self.managed_worktrees.lock()
13538 .unwrap_or_else(|poisoned| poisoned.into_inner())
13539 .release_session(
13540 &lease_id,
13541 self.incarnation_id,
13542 address.session.instance_id,
13543 address.session.generation,
13544 unix_time_ms(),
13545 );
13546 if self.persist_state().is_err() {
13547 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13548 }
13549 if let Some(snapshot) = snapshot {
13550 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13551 }
13552 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13553 }
13554 Ok(())
13555 }
13556
13557 async fn wait_until_removed(
13603 &self,
13604 address: &SessionAddress,
13605 commit_timeout: Duration,
13606 ) -> Result<(), NodeFailure> {
13607 let deadline = Instant::now() + commit_timeout;
13608 let mut next_dispatch = Instant::now();
13609 loop {
13610 let snapshot = self.handle.snapshot();
13611 let status = snapshot
13612 .sessions
13613 .iter()
13614 .find(|session| session.instance_id == address.session.instance_id)
13615 .map(|session| session.status.clone());
13616 let Some(status) = status else {
13617 return Ok(());
13618 };
13619 let now = Instant::now();
13620 if now >= deadline {
13621 return Err(failure(
13622 NodeFailureCode::BackendBusy,
13623 "remove did not commit before the bounded deadline; session binding was retained",
13624 ));
13625 }
13626 if status.allows_remove() && now >= next_dispatch {
13627 let _ = self.dispatch(ControlCommand::Remove {
13628 instance_id: address.session.instance_id,
13629 });
13630 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13631 }
13632 sleep(Duration::from_millis(2)).await;
13633 }
13634 }
13635
13636 fn resync(&self, after_sequence: u64) -> NodeResponse {
13637 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13638 let event_sequence = history.last_sequence;
13639 let oldest_available_sequence = history.replay_floor_sequence;
13640 let events = history
13641 .events
13642 .iter()
13643 .filter(|event| event.sequence > after_sequence)
13644 .cloned()
13645 .collect();
13646 drop(history);
13647 NodeResponse::Resync {
13648 event_sequence,
13649 oldest_available_sequence,
13650 snapshot: self.snapshot(),
13651 events,
13652 }
13653 }
13654}
13655
13656fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13661 match kind {
13662 AgentStreamChunkKindV1::Text { .. } => "text",
13663 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13664 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13665 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13666 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13667 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13668 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13669 }
13670}
13671
13672async fn accept_connections(
13686 endpoint: &str,
13687 call_home: Option<std::net::SocketAddr>,
13688 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13689 shared: Arc<NodeShared>,
13690) -> Result<(), NodeServerError> {
13691 let mut connections = JoinSet::new();
13692 let result = match call_home {
13693 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13694 Some(relay) => {
13695 let listener =
13696 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13697 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13698 tokio::pin!(listener);
13699 tokio::pin!(dialler);
13700 tokio::select! {
13701 result = &mut listener => result,
13702 result = &mut dialler => result,
13703 }
13704 }
13705 };
13706 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13707 while !connections.is_empty() {
13708 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13709 if remaining.is_zero()
13710 || timeout(remaining, connections.join_next()).await.is_err()
13711 {
13712 break;
13713 }
13714 }
13715 connections.abort_all();
13716 while connections.join_next().await.is_some() {}
13717 result
13718}
13719
13720const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13724
13725async fn call_home_loop(
13739 relay: std::net::SocketAddr,
13740 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13741 shared: Arc<NodeShared>,
13742) -> Result<(), NodeServerError> {
13743 let mut failures = 0_usize;
13744 loop {
13745 if shared.shutdown.load(Ordering::Acquire) {
13746 return Ok(());
13747 }
13748 let permit = tokio::select! {
13749 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13750 permit.map_err(|_| NodeServerError::PreauthClosed)?
13751 }
13752 _ = shared.shutdown_notify.notified() => return Ok(()),
13753 };
13754 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13755 Ok(()) => {
13756 failures = 0;
13760 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13761 }
13762 Err(error) => {
13763 failures = failures.saturating_add(1);
13764 tracing::warn!(
13765 node_id = %shared.node_id,
13766 %relay,
13767 cause = %error,
13768 attempt = failures,
13769 "call-home connection failed",
13770 );
13771 let backoff = call_home_backoff(failures);
13772 tokio::select! {
13773 _ = sleep(backoff) => {}
13774 _ = shared.shutdown_notify.notified() => return Ok(()),
13775 }
13776 }
13777 }
13778 }
13779}
13780
13781const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13783const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13785
13786fn call_home_keepalive() -> socket2::TcpKeepalive {
13787 let keepalive = socket2::TcpKeepalive::new()
13788 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13789 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13790 #[cfg(not(windows))]
13791 let keepalive = keepalive.with_retries(3);
13792 keepalive
13793}
13794
13795fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13796 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13797}
13798
13799pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13801 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13802}
13803
13804fn call_home_backoff(attempt: usize) -> Duration {
13806 Duration::from_millis(
13807 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13808 )
13809}
13810
13811
13812async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13815 #[cfg(feature = "wireguard")]
13816 {
13817 let std_stream = tokio::task::spawn_blocking(move || {
13818 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13819 })
13820 .await
13821 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13822 .map_err(NodeServerError::CallHomeTransport)?;
13823 std_stream
13824 .set_nonblocking(true)
13825 .map_err(NodeServerError::CallHomeTransport)?;
13826 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13827 }
13828 #[cfg(not(feature = "wireguard"))]
13829 {
13830 tokio::net::TcpStream::connect(relay)
13831 .await
13832 .map_err(NodeServerError::CallHomeTransport)
13833 }
13834}
13835
13836async fn dial_relay_and_serve(
13838 relay: std::net::SocketAddr,
13839 identity: Option<&hatchery_enroll::NodeIdentity>,
13840 shared: Arc<NodeShared>,
13841 preauth_permit: OwnedSemaphorePermit,
13842) -> Result<(), NodeServerError> {
13843 let mut stream = dial_call_home(relay).await?;
13844 let _ = stream.set_nodelay(true);
13848 if let Err(error) = enable_call_home_keepalive(&stream) {
13854 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13855 }
13856 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13857 .await
13858 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13859 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13860 let mut session_token = None;
13861 if let Some(identity) = identity {
13862 let mut magic = [0_u8; 4];
13866 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13867 .await
13868 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13869 .map_err(NodeServerError::CallHomeTransport)?;
13870 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13871 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13872 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13873 .await
13874 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13875 .map_err(NodeServerError::CallHomeTransport)?;
13876 let (answer, token) = identity
13877 .answer_challenge(shared.node_id.as_str(), &frame)
13878 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13879 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13880 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13881 session_token = Some(token);
13882 }
13883 }
13884 serve_connection(stream, shared, session_token, preauth_permit).await
13885}
13886
13887async fn accept_connections_inner(
13888 endpoint: &str,
13889 shared: Arc<NodeShared>,
13890 connections: &mut JoinSet<Result<(), NodeServerError>>,
13891) -> Result<(), NodeServerError> {
13892 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13893 loop {
13894 while connections.try_join_next().is_some() {}
13895 if shared.shutdown.load(Ordering::Acquire) {
13896 return Ok(());
13897 }
13898 let preauth_permit = tokio::select! {
13899 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13900 permit.map_err(|_| NodeServerError::PreauthClosed)?
13901 }
13902 _ = shared.shutdown_notify.notified() => return Ok(()),
13903 };
13904 let server = tokio::select! {
13905 result = listener.accept() => result?,
13906 _ = shared.shutdown_notify.notified() => return Ok(()),
13907 };
13908 let connection_shared = Arc::clone(&shared);
13909 connections.spawn(async move {
13910 serve_connection(server, connection_shared, None, preauth_permit).await
13911 });
13912 if shared.shutdown.load(Ordering::Acquire) {
13913 return Ok(());
13914 }
13915 }
13916}
13917
13918async fn serve_connection<S>(
13926 mut pipe: S,
13927 shared: Arc<NodeShared>,
13928 session_token: Option<String>,
13929 preauth_permit: OwnedSemaphorePermit,
13930) -> Result<(), NodeServerError>
13931where
13932 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13933{
13934 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13936 let hello_frame = timeout(
13937 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13938 read_json_frame_limited_body_timeout(
13939 &mut pipe,
13940 MAX_NODE_HELLO_FRAME_BYTES,
13941 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13942 ),
13943 )
13944 .await
13945 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13946 let ClientFrame::Hello(hello) = hello_frame else {
13947 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13948 };
13949 if hello.build_stamp != BUILD_STAMP {
13950 return Err(NodeServerError::Handshake(format!(
13951 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13952 hello.build_stamp,
13953 )));
13954 }
13955 let compatibility = match hello.compatibility.as_ref() {
13956 Some(offer) => {
13957 let mut selected = node_compatibility_support(&shared)?
13958 .negotiate(offer)
13959 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13960 project_negotiated_provider_ids(&mut selected);
13961 Some(selected)
13962 }
13963 None => None,
13964 };
13965 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13966 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13967 (Some(offer), Some(selected)) => negotiated_auth_proof(
13968 auth_token,
13969 AuthDirection::Server,
13970 hello.role,
13971 &hello.client_nonce,
13972 &server_nonce,
13973 offer,
13974 selected,
13975 ),
13976 (None, None) => auth_proof(
13977 auth_token,
13978 AuthDirection::Server,
13979 hello.role,
13980 &hello.client_nonce,
13981 &server_nonce,
13982 ),
13983 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13984 }
13985 .map_err(NodeServerError::Authentication)?;
13986 write_json_frame_limited(
13987 &mut pipe,
13988 &ServerFrame::Challenge(ServerChallenge {
13989 build_stamp: BUILD_STAMP.to_owned(),
13990 server_nonce,
13991 server_proof,
13992 compatibility: compatibility.clone(),
13993 }),
13994 MAX_NODE_HELLO_FRAME_BYTES,
13995 )
13996 .await?;
13997 let authentication = timeout(
13998 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13999 read_json_frame_limited_body_timeout(
14000 &mut pipe,
14001 MAX_NODE_HELLO_FRAME_BYTES,
14002 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14003 ),
14004 )
14005 .await
14006 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
14007 let ClientFrame::Authenticate(authentication) = authentication else {
14008 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
14009 };
14010 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
14011 (Some(offer), Some(selected)) => negotiated_auth_proof(
14012 auth_token,
14013 AuthDirection::Client,
14014 hello.role,
14015 &hello.client_nonce,
14016 &server_nonce,
14017 offer,
14018 selected,
14019 ),
14020 (None, None) => auth_proof(
14021 auth_token,
14022 AuthDirection::Client,
14023 hello.role,
14024 &hello.client_nonce,
14025 &server_nonce,
14026 ),
14027 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
14028 }
14029 .map_err(NodeServerError::Authentication)?;
14030 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
14031 return Err(NodeServerError::Handshake("access denied".to_owned()));
14032 }
14033 let selected_capabilities = compatibility
14034 .as_ref()
14035 .map(|selected| selected.capabilities.clone())
14036 .unwrap_or_default();
14037 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
14038 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
14039 });
14040 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
14041 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14042 });
14043 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
14044 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
14045 });
14046 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
14047 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
14048 });
14049 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
14050 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
14051 });
14052 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
14053 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
14054 }) && selected_capabilities.iter().any(|capability| {
14055 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
14056 });
14057 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
14058 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
14059 });
14060 let include_session_bundles = selected_capabilities.iter().any(|capability| {
14061 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
14062 });
14063 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
14064 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
14065 });
14066 let include_agent_progress = selected_capabilities.iter().any(|capability| {
14067 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
14068 });
14069 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
14070 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14071 });
14072 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14073 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14074 });
14075 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14076 .try_acquire_owned()
14077 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14078 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14079 let _release_guard = ControllerReleaseGuard {
14080 shared: Arc::clone(&shared),
14081 connection_id,
14082 };
14083 drop(preauth_permit);
14084 let _authenticated_permit = authenticated_permit;
14085 let mut event_rx = shared.event_tx.subscribe();
14086 let mut terminal_event_rx = terminal_event_subscription(
14087 &shared,
14088 include_terminal_frame_events,
14089 );
14090 let mut agent_stream_event_rx = agent_stream_event_subscription(
14091 &shared,
14092 include_agent_stream_events,
14093 );
14094 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14095 &shared,
14096 include_harness_mcp_proxy,
14097 );
14098 write_json_frame(
14099 &mut pipe,
14100 &ServerFrame::Hello(NodeHello {
14101 build_stamp: BUILD_STAMP.to_owned(),
14102 incarnation_id: shared.incarnation_id,
14103 connection_id,
14104 role: hello.role,
14105 event_sequence: shared.current_sequence(),
14106 controller: shared.controller_state(),
14107 snapshot: snapshot_for_wire(
14108 &shared,
14109 include_provider_runtime_status,
14110 include_open_provider_ids,
14111 include_spawn_profiles,
14112 include_managed_worktrees,
14113 include_child_environment_profiles,
14114 include_session_bundles,
14115 include_history_context_packs,
14116 include_agent_progress,
14117 include_session_task_correlation,
14118 ),
14119 compatibility,
14120 }),
14121 )
14122 .await?;
14123 let (mut reader, mut writer) = tokio::io::split(pipe);
14124 let (frame_tx, mut frame_rx) = mpsc::channel(16);
14125 let reader_task = tokio::spawn(async move {
14126 loop {
14127 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14128 &mut reader,
14129 MAX_NODE_CLIENT_FRAME_BYTES,
14130 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14131 )
14132 .await;
14133 let terminal = frame.is_err();
14134 if frame_tx.send(frame).await.is_err() || terminal {
14135 break;
14136 }
14137 }
14138 });
14139 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14140 let mut pending_events = Vec::new();
14141 let mut resync_required = false;
14142 let mut discard_events_through = 0_u64;
14143 loop {
14144 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14149 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14150 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14151 for _ in 0..durable_drain_budget {
14152 match event_rx.try_recv() {
14153 Ok(event) => queue_connection_event(
14154 &mut pending_events,
14155 event,
14156 discard_events_through,
14157 ),
14158 Err(broadcast::error::TryRecvError::Empty) => break,
14159 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14160 resync_required = true;
14161 break;
14162 }
14163 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14164 }
14165 }
14166 if let Some(receiver) = terminal_event_rx.as_mut() {
14167 match receiver.try_recv() {
14168 Ok(events) => queue_connection_event_batch(
14169 &mut pending_events,
14170 &events,
14171 discard_events_through,
14172 ),
14173 Err(broadcast::error::TryRecvError::Empty) => {}
14174 Err(broadcast::error::TryRecvError::Lagged(_)) => {
14175 resync_required = true;
14176 }
14177 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14178 }
14179 }
14180 if let Some(receiver) = agent_stream_event_rx.as_mut() {
14181 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14191 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14192 for _ in 0..agent_stream_drain_budget {
14193 match receiver.try_recv() {
14194 Ok(events) => queue_connection_event_batch(
14195 &mut pending_events,
14196 &events,
14197 discard_events_through,
14198 ),
14199 Err(broadcast::error::TryRecvError::Empty) => break,
14200 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14201 tracing::warn!(
14202 connection_id,
14203 dropped,
14204 "agent stream event subscription lagged; forcing a resync",
14205 );
14206 resync_required = true;
14207 break;
14208 }
14209 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14210 }
14211 }
14212 }
14213 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14214 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14220 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14221 for _ in 0..harness_mcp_proxy_drain_budget {
14222 match receiver.try_recv() {
14223 Ok(events) => queue_connection_event_batch(
14224 &mut pending_events,
14225 &events,
14226 discard_events_through,
14227 ),
14228 Err(broadcast::error::TryRecvError::Empty) => break,
14229 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14230 tracing::warn!(
14231 connection_id,
14232 dropped,
14233 "harness MCP proxy event subscription lagged; forcing a resync",
14234 );
14235 resync_required = true;
14236 break;
14237 }
14238 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14239 }
14240 }
14241 }
14242 if resync_required {
14243 let event = resync_required_event(&shared);
14244 apply_connection_resync_watermark(
14245 &mut pending_events,
14246 &mut discard_events_through,
14247 event.sequence,
14248 );
14249 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14250 resync_required = false;
14251 continue;
14252 }
14253 if !pending_events.is_empty() {
14254 pending_events.sort_unstable_by_key(|event| event.sequence);
14255 let burst_len = connection_event_burst_len(pending_events.len());
14256 shared
14257 .connection_events_sent
14258 .fetch_add(burst_len as u64, Ordering::Relaxed);
14259 for event in pending_events.drain(..burst_len) {
14260 let event = if include_managed_worktrees {
14261 Some(event)
14262 } else {
14263 project_event_without_managed_worktrees(event)
14264 };
14265 let event = event.and_then(|event| if include_open_provider_ids {
14266 Some(event)
14267 } else {
14268 project_event_legacy_provider_ids(&shared, event)
14269 });
14270 let event = event.map(|event| if include_child_environment_profiles {
14271 event
14272 } else {
14273 project_event_without_child_environment_profile(event)
14274 });
14275 let event = event.map(|event| if include_session_bundles {
14276 event
14277 } else {
14278 project_event_without_session_bundle(event)
14279 });
14280 let event = event.map(|event| {
14281 project_event_history_for_wire(event, include_history_context_packs)
14282 });
14283 let event = event.map(|event| if include_history_context_packs {
14284 event
14285 } else {
14286 project_event_without_context_pack(event)
14287 });
14288 let event = event.map(|event| if include_session_task_correlation {
14289 event
14290 } else {
14291 project_event_without_session_task_binding(event)
14292 });
14293 let exact_harness_controller = hello.role == ClientRole::Operator
14294 && shared.controller_state().is_some_and(|controller| {
14295 controller.connection_id == connection_id
14296 });
14297 let event = event.and_then(|event| {
14298 if include_harness_mcp_proxy && exact_harness_controller {
14299 Some(event)
14300 } else {
14301 project_event_without_harness_mcp_proxy(event)
14302 }
14303 });
14304 if let Some(event) = event {
14305 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14306 }
14307 }
14308 }
14309 tokio::select! {
14310 biased;
14311 frame = frame_rx.recv() => {
14312 let frame = match frame {
14313 Some(frame) => frame,
14314 None => break,
14315 };
14316 let frame = match frame {
14317 Ok(frame) => frame,
14318 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14319 Err(error) => return Err(error.into()),
14320 };
14321 let ClientFrame::Request(request) = frame else {
14322 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14323 };
14324 #[cfg(feature = "fixture")]
14325 let probe_spawn_managed_worktree_v2 = matches!(
14326 &request.request,
14327 NodeRequest::SpawnManagedWorktreeV2 { .. }
14328 );
14329 let requires_open_provider_ids =
14330 request_requires_open_provider_ids(&shared, &request.request);
14331 let requires_child_environment_profile =
14332 request_requires_child_environment_profile(&shared, &request.request);
14333 let requires_session_bundle =
14334 request_requires_session_bundle(&shared, &request.request);
14335 let requires_history_context_pack =
14336 request_requires_history_context_pack(&shared, &request.request);
14337 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14338 ResponseEnvelope {
14339 request_id: request.request_id,
14340 result: Err(failure(
14341 NodeFailureCode::UnsupportedCapability,
14342 "open provider IDs were not negotiated",
14343 )),
14344 }
14345 } else if requires_child_environment_profile
14346 && !include_child_environment_profiles
14347 {
14348 ResponseEnvelope {
14349 request_id: request.request_id,
14350 result: Err(failure(
14351 NodeFailureCode::UnsupportedCapability,
14352 "child environment profiles were not negotiated",
14353 )),
14354 }
14355 } else if requires_session_bundle && !include_session_bundles {
14356 ResponseEnvelope {
14357 request_id: request.request_id,
14358 result: Err(failure(
14359 NodeFailureCode::UnsupportedCapability,
14360 "session bundles were not negotiated",
14361 )),
14362 }
14363 } else if requires_history_context_pack && !include_history_context_packs {
14364 ResponseEnvelope {
14365 request_id: request.request_id,
14366 result: Err(failure(
14367 NodeFailureCode::UnsupportedCapability,
14368 "history context packs were not negotiated",
14369 )),
14370 }
14371 } else if request_uses_unnegotiated_capability(
14372 &request.request,
14373 &selected_capabilities,
14374 )
14375 {
14376 ResponseEnvelope {
14377 request_id: request.request_id,
14378 result: Err(failure(
14379 NodeFailureCode::UnsupportedCapability,
14380 "request capability was not negotiated",
14381 )),
14382 }
14383 } else {
14384 process_request(&shared, connection_id, hello.role, request).await
14385 };
14386 #[cfg(feature = "fixture")]
14387 if probe_spawn_managed_worktree_v2 {
14388 shared
14389 .fixture_spawn_managed_worktree_v2_failure_probe
14390 .record(reply.result.as_ref().err().map(|failure| failure.code));
14391 }
14392 if !include_provider_runtime_status {
14393 clear_response_provider_runtime_status(&mut reply);
14394 }
14395 if !include_managed_worktrees {
14396 project_response_without_managed_worktrees(&mut reply);
14397 }
14398 if !include_open_provider_ids {
14399 project_response_legacy_provider_ids(&shared, &mut reply);
14400 }
14401 if !include_child_environment_profiles {
14402 project_response_without_child_environment_profile(&mut reply);
14403 }
14404 if !include_session_bundles {
14405 project_response_without_session_bundle(&mut reply);
14406 }
14407 project_response_history_for_wire(
14408 &mut reply,
14409 include_history_context_packs,
14410 );
14411 if !include_history_context_packs {
14412 project_response_without_context_pack(&mut reply);
14413 }
14414 if !include_agent_progress {
14415 project_response_without_agent_progress(&mut reply);
14416 }
14417 if !include_session_task_correlation {
14418 project_response_without_session_task_binding(&mut reply);
14419 }
14420 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14421 }
14422 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14423 event = event_rx.recv() => {
14424 match event {
14425 Ok(event) => queue_connection_event(
14426 &mut pending_events,
14427 event,
14428 discard_events_through,
14429 ),
14430 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14431 Err(broadcast::error::RecvError::Closed) => break,
14432 }
14433 }
14434 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14435 match events {
14436 Ok(events) => queue_connection_event_batch(
14437 &mut pending_events,
14438 &events,
14439 discard_events_through,
14440 ),
14441 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14442 Err(broadcast::error::RecvError::Closed) => break,
14443 }
14444 }
14445 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14446 match events {
14447 Ok(events) => queue_connection_event_batch(
14448 &mut pending_events,
14449 &events,
14450 discard_events_through,
14451 ),
14452 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14453 Err(broadcast::error::RecvError::Closed) => break,
14454 }
14455 }
14456 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14457 match events {
14458 Ok(events) => queue_connection_event_batch(
14459 &mut pending_events,
14460 &events,
14461 discard_events_through,
14462 ),
14463 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14464 Err(broadcast::error::RecvError::Closed) => break,
14465 }
14466 }
14467 _ = shared.shutdown_notify.notified() => break,
14468 }
14469 }
14470 Ok(())
14471}
14472
14473async fn receive_terminal_event_batch(
14474 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14475) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14476 match receiver {
14477 Some(receiver) => receiver.recv().await,
14478 None => std::future::pending().await,
14479 }
14480}
14481
14482fn terminal_event_subscription(
14483 shared: &NodeShared,
14484 enabled: bool,
14485) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14486 enabled.then(|| shared.terminal_event_tx.subscribe())
14487}
14488
14489fn agent_stream_event_subscription(
14493 shared: &NodeShared,
14494 enabled: bool,
14495) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14496 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14497}
14498
14499fn harness_mcp_proxy_event_subscription(
14504 shared: &NodeShared,
14505 enabled: bool,
14506) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14507 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14508}
14509
14510fn queue_connection_event(
14511 pending: &mut Vec<NodeEventEnvelope>,
14512 event: NodeEventEnvelope,
14513 discard_through: u64,
14514) {
14515 if event.sequence <= discard_through
14516 && !event.event.requires_harness_mcp_proxy_capability()
14517 {
14518 return;
14519 }
14520 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14521 if let Some(index) = pending.iter().position(|current| {
14522 matches!(
14523 ¤t.event,
14524 NodeEvent::TerminalFrame {
14525 address: current_address,
14526 ..
14527 } if current_address == address
14528 )
14529 }) {
14530 pending.remove(index);
14531 }
14532 }
14533 pending.push(event);
14534}
14535
14536fn project_event_without_harness_mcp_proxy(
14537 envelope: NodeEventEnvelope,
14538) -> Option<NodeEventEnvelope> {
14539 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14540}
14541
14542fn queue_connection_event_batch(
14543 pending: &mut Vec<NodeEventEnvelope>,
14544 events: &[NodeEventEnvelope],
14545 discard_through: u64,
14546) {
14547 for event in events {
14548 queue_connection_event(pending, event.clone(), discard_through);
14549 }
14550}
14551
14552fn apply_connection_resync_watermark(
14553 pending: &mut Vec<NodeEventEnvelope>,
14554 discard_through: &mut u64,
14555 marker_sequence: u64,
14556) {
14557 *discard_through = (*discard_through).max(marker_sequence);
14558 pending.retain(|event| event.sequence > *discard_through);
14559}
14560
14561fn connection_event_burst_len(pending_len: usize) -> usize {
14562 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14563}
14564
14565fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14566 let history = shared
14567 .history
14568 .lock()
14569 .unwrap_or_else(|poisoned| poisoned.into_inner());
14570 let sequence = history.last_sequence;
14571 let oldest_available_sequence = history.replay_floor_sequence;
14572 NodeEventEnvelope {
14573 sequence,
14574 event: NodeEvent::ResyncRequired {
14575 oldest_available_sequence,
14576 },
14577 }
14578}
14579
14580fn node_compatibility_support(
14581 shared: &NodeShared,
14582) -> Result<NodeCompatibilitySupport, NodeServerError> {
14583 let mut support = node_compatibility_support_for_manifest(
14584 &shared.provider_contracts,
14585 &shared.provider_adapter_contracts,
14586 )?;
14587 if shared
14588 .delivery_store
14589 .lock()
14590 .unwrap_or_else(|poisoned| poisoned.into_inner())
14591 .is_some()
14592 {
14593 support.capabilities.push(
14594 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14595 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14596 );
14597 }
14598 if shared.harness_mcp_registry.is_some() {
14599 support.capabilities.push(
14600 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14601 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14602 );
14603 }
14604 Ok(support)
14605}
14606
14607fn node_compatibility_support_for_manifest(
14608 provider_contracts: &[ProviderContractSupport],
14609 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14610) -> Result<NodeCompatibilitySupport, NodeServerError> {
14611 Ok(NodeCompatibilitySupport {
14612 build_stamp: BUILD_STAMP.to_owned(),
14613 capabilities: baseline_capabilities()?,
14614 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14615 path_semantics: platform::path_semantics(),
14616 local_transport: platform::local_transport(),
14617 state_schema: StateSchemaSupport {
14618 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14619 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14620 },
14621 provider_contracts: provider_contracts.to_vec(),
14622 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14623 })
14624}
14625
14626fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14627 let capabilities = [
14628 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14629 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14630 NODE_REPOSITORY_PATH_CAPABILITY,
14631 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14632 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14633 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14634 NODE_GIT_READ_CAPABILITY,
14635 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14636 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14637 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14638 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14639 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14640 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14641 NODE_ACP_CONTROL_CAPABILITY,
14642 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14643 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14644 NODE_WORKTREE_SELECTION_CAPABILITY,
14645 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14646 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14647 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14648 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14649 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14650 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14651 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14652 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14653 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14654 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14655 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14656 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14657 ]
14658 .into_iter()
14659 .map(|capability| {
14660 CapabilityId::new(capability)
14661 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14662 })
14663 .collect::<Result<Vec<_>, _>>()?;
14664 #[cfg(windows)]
14665 let capabilities = {
14666 let mut capabilities = capabilities;
14667 capabilities.push(
14668 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14669 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14670 );
14671 capabilities
14672 };
14673 Ok(capabilities)
14674}
14675
14676fn request_uses_unnegotiated_capability(
14677 request: &NodeRequest,
14678 selected_capabilities: &[CapabilityId],
14679) -> bool {
14680 let selected = |required: &str| {
14681 selected_capabilities
14682 .iter()
14683 .any(|capability| capability.as_str() == required)
14684 };
14685 request
14686 .required_capability()
14687 .is_some_and(|required| !selected(required))
14688 || (request.requires_worktree_selection_capability()
14689 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14690 || (request.requires_spawn_spec_defaults_overrides_capability()
14691 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14692 || (request.requires_spawn_profile_revision_capability()
14693 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14694 || (request.requires_child_environment_profile_capability()
14695 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14696 || (request.requires_session_bundle_materialization_capability()
14697 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14698 || (request.requires_history_context_pack_capability()
14699 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14700}
14701
14702fn snapshot_for_wire(
14703 shared: &NodeShared,
14704 include_provider_runtime_status: bool,
14705 include_open_provider_ids: bool,
14706 include_spawn_profiles: bool,
14707 include_managed_worktrees: bool,
14708 include_child_environment_profiles: bool,
14709 include_session_bundles: bool,
14710 include_history_context_packs: bool,
14711 include_agent_progress: bool,
14712 include_session_task_correlation: bool,
14713) -> NodeSnapshot {
14714 let mut snapshot = shared.snapshot();
14715 if !include_provider_runtime_status {
14716 snapshot.provider_runtime_statuses.clear();
14717 }
14718 if !include_open_provider_ids {
14719 project_snapshot_legacy_provider_ids(&mut snapshot);
14720 }
14721 if !include_managed_worktrees {
14722 snapshot.managed_worktrees.clear();
14723 for workspace in &mut snapshot.workspaces {
14724 workspace.managed_worktree_profiles = None;
14725 }
14726 }
14727 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14728 if !include_spawn_profiles {
14729 inventory.spawn_profiles = None;
14730 }
14731 if !include_session_bundles {
14732 inventory.bundles = None;
14733 }
14734 if inventory.spawn_profiles.is_none()
14735 && inventory.bundles.is_none()
14736 && inventory.network_allowlists.is_none()
14737 {
14738 snapshot.launch_inventory = None;
14739 }
14740 }
14741 if !include_child_environment_profiles {
14742 clear_snapshot_child_environment_profiles(&mut snapshot);
14743 }
14744 if !include_session_bundles {
14745 clear_snapshot_session_bundles(&mut snapshot);
14746 }
14747 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14748 if !include_history_context_packs {
14749 clear_snapshot_context_packs(&mut snapshot);
14750 }
14751 if !include_agent_progress {
14752 snapshot.agent_progress.clear();
14753 }
14754 if !include_session_task_correlation {
14755 clear_snapshot_session_task_bindings(&mut snapshot);
14756 }
14757 snapshot
14758}
14759
14760fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14761 for record in &mut snapshot.session_records {
14762 record.task_binding = None;
14763 }
14764}
14765
14766fn project_event_without_session_task_binding(
14767 mut envelope: NodeEventEnvelope,
14768) -> NodeEventEnvelope {
14769 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14770 record.task_binding = None;
14771 }
14772 envelope
14773}
14774
14775fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14776 let Ok(response) = reply.result.as_mut() else { return };
14777 match response {
14778 NodeResponse::Snapshot { snapshot, .. } => {
14779 clear_snapshot_session_task_bindings(snapshot);
14780 }
14781 NodeResponse::Resync { snapshot, events, .. } => {
14782 clear_snapshot_session_task_bindings(snapshot);
14783 for event in events {
14784 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14785 record.task_binding = None;
14786 }
14787 }
14788 }
14789 NodeResponse::SessionRecordUpdated { record }
14790 | NodeResponse::ProviderSessionIndexed { record }
14791 | NodeResponse::NativeSessionIndexed { record, .. }
14792 | NodeResponse::SessionRecordResumed { record, .. } => {
14793 record.task_binding = None;
14794 }
14795 _ => {}
14796 }
14797}
14798
14799fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14800 let Ok(response) = reply.result.as_mut() else { return };
14801 match response {
14802 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14803 snapshot.agent_progress.clear();
14804 }
14805 _ => {}
14806 }
14807}
14808
14809fn project_event_without_managed_worktrees(
14810 mut envelope: NodeEventEnvelope,
14811) -> Option<NodeEventEnvelope> {
14812 if matches!(
14813 envelope.event,
14814 NodeEvent::ManagedWorktreeUpserted { .. }
14815 | NodeEvent::ManagedWorktreeRemoved { .. }
14816 ) {
14817 return None;
14818 }
14819 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14820 workspace.managed_worktree_profiles = None;
14821 }
14822 Some(envelope)
14823}
14824
14825fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14826 if matches!(
14827 reply.result,
14828 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14829 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14830 ) {
14831 reply.result = Err(failure(
14832 NodeFailureCode::UnsupportedCapability,
14833 "managed worktree capability was not negotiated",
14834 ));
14835 return;
14836 }
14837 let Ok(response) = reply.result.as_mut() else { return };
14838 match response {
14839 NodeResponse::Snapshot { snapshot, .. } => {
14840 snapshot.managed_worktrees.clear();
14841 for workspace in &mut snapshot.workspaces {
14842 workspace.managed_worktree_profiles = None;
14843 }
14844 }
14845 NodeResponse::Resync { snapshot, events, .. } => {
14846 snapshot.managed_worktrees.clear();
14847 for workspace in &mut snapshot.workspaces {
14848 workspace.managed_worktree_profiles = None;
14849 }
14850 *events = std::mem::take(events)
14851 .into_iter()
14852 .filter_map(project_event_without_managed_worktrees)
14853 .collect();
14854 }
14855 NodeResponse::WorkspaceRegistered { workspace }
14856 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14857 | NodeResponse::WorktreeCreated { workspace, .. } => {
14858 workspace.managed_worktree_profiles = None;
14859 }
14860 NodeResponse::WorkspaceInspected { inspection } => {
14861 inspection.git.managed_worktree = None;
14862 }
14863 _ => {}
14864 }
14865}
14866
14867fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14868 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14869 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14870 });
14871 if includes_open_provider_ids {
14872 return;
14873 }
14874 compatibility
14875 .provider_contracts
14876 .retain(|contract| provider_id_is_legacy(&contract.provider));
14877 compatibility
14878 .provider_adapter_contracts
14879 .retain(|contract| provider_id_is_legacy(&contract.provider));
14880}
14881
14882fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14883 if let Some(spec) = match request {
14884 NodeRequest::SpawnSpec { spec }
14885 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14886 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14887 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14888 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14889 _ => None,
14890 } {
14891 return shared
14892 .resolve_spawn_spec(spec)
14893 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14894 }
14895 request_requires_open_provider_ids_with(
14896 request,
14897 |session| shared.validate_address(session).ok(),
14898 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14899 )
14900}
14901
14902fn request_requires_child_environment_profile(
14903 shared: &NodeShared,
14904 request: &NodeRequest,
14905) -> bool {
14906 if let Some(spec) = match request {
14907 NodeRequest::SpawnSpec { spec }
14908 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14909 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14910 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14911 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14912 _ => None,
14913 } {
14914 return shared
14915 .resolve_spawn_spec(spec)
14916 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14917 }
14918 match request {
14919 NodeRequest::Resume { session, .. }
14920 | NodeRequest::Prompt { session, .. }
14921 | NodeRequest::Paste { session, .. }
14922 | NodeRequest::Input { session, .. }
14923 | NodeRequest::TerminalBytes { session, .. }
14924 | NodeRequest::TerminalControl { session, .. }
14925 | NodeRequest::Resize { session, .. }
14926 | NodeRequest::Interrupt { session }
14927 | NodeRequest::Stop { session, .. }
14928 | NodeRequest::Remove { session }
14929 | NodeRequest::ResolveInteraction { session, .. }
14930 | NodeRequest::SetSessionMode { session, .. }
14931 | NodeRequest::SetSessionConfigOption { session, .. }
14932 | NodeRequest::SetSessionModel { session, .. }
14933 | NodeRequest::DiscoverHistory { session, .. }
14934 | NodeRequest::LoadHistory { session, .. }
14935 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14936 | NodeRequest::ExportContextPack { session } => shared
14937 .session_bindings
14938 .lock()
14939 .unwrap_or_else(|poisoned| poisoned.into_inner())
14940 .get(&session.session.instance_id)
14941 .filter(|binding| {
14942 binding.workspace_id == session.workspace_id
14943 && binding.generation == session.session.generation
14944 })
14945 .map_or(true, |binding| binding.environment_profile.is_some()),
14946 NodeRequest::RenameSessionRecord { record_id, .. }
14947 | NodeRequest::SetSessionTask { record_id, .. }
14948 | NodeRequest::ResumeSessionRecord { record_id, .. }
14949 | NodeRequest::ForgetSessionRecord { record_id } => shared
14950 .record(record_id)
14951 .map_or(true, |record| record.environment_profile.is_some()),
14952 NodeRequest::ArmHarnessMcpReservation { .. }
14953 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14954 | NodeRequest::ActivateHarnessMcpReservation { .. }
14955 | NodeRequest::AbortHarnessMcpReservation { .. }
14956 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14957 | NodeRequest::RejectHarnessMcpCall { .. }
14958 | NodeRequest::Snapshot
14959 | NodeRequest::Resync { .. }
14960 | NodeRequest::BeginDeliveryStage { .. }
14961 | NodeRequest::PutDeliveryBlobChunk { .. }
14962 | NodeRequest::CommitDeliveryStage { .. }
14963 | NodeRequest::AbortDeliveryStage { .. }
14964 | NodeRequest::BrowseHostDirectories { .. }
14965 | NodeRequest::InspectWorkspace { .. }
14966 | NodeRequest::ReadWorkspaceFile { .. }
14967 | NodeRequest::WriteWorkspaceFile { .. }
14968 | NodeRequest::CreateWorkspaceFile { .. }
14969 | NodeRequest::CreateWorkspaceDirectory { .. }
14970 | NodeRequest::ReadGitHistory { .. }
14971 | NodeRequest::ReadGitDiff { .. }
14972 | NodeRequest::AcquireController { .. }
14973 | NodeRequest::ReleaseController
14974 | NodeRequest::Spawn { .. }
14975 | NodeRequest::SpawnWithOptions { .. }
14976 | NodeRequest::SpawnSpec { .. }
14977 | NodeRequest::RegisterWorkspace { .. }
14978 | NodeRequest::CreateStandaloneWorkspace { .. }
14979 | NodeRequest::UnregisterWorkspace { .. }
14980 | NodeRequest::CreateWorktree { .. }
14981 | NodeRequest::RemoveWorktree { .. }
14982 | NodeRequest::SpawnManagedWorktree { .. }
14983 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14984 | NodeRequest::CleanupManagedWorktree { .. }
14985 | NodeRequest::IndexProviderSession { .. }
14986 | NodeRequest::IndexNativeSession { .. }
14987 | NodeRequest::CatalogNativeSessions { .. }
14988 | NodeRequest::PageNativeSessions { .. }
14989 | NodeRequest::PreviewNativeSession { .. }
14990 | NodeRequest::PreviewSessionRecord { .. }
14991 | NodeRequest::ForgetContextPack { .. }
14992 | NodeRequest::ResolveDurableContextPack { .. }
14993 | NodeRequest::ReadContextPack { .. }
14994 | NodeRequest::Shutdown => false,
14995 }
14996}
14997
14998fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14999 if let Some(spec) = match request {
15000 NodeRequest::SpawnSpec { spec }
15001 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15002 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15003 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15004 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15005 _ => None,
15006 } {
15007 return shared
15008 .resolve_spawn_spec(spec)
15009 .is_ok_and(|resolved| resolved.bundle_id.is_some());
15010 }
15011 match request {
15012 NodeRequest::Resume { session, .. }
15013 | NodeRequest::Prompt { session, .. }
15014 | NodeRequest::Paste { session, .. }
15015 | NodeRequest::Input { session, .. }
15016 | NodeRequest::TerminalBytes { session, .. }
15017 | NodeRequest::TerminalControl { session, .. }
15018 | NodeRequest::Resize { session, .. }
15019 | NodeRequest::Interrupt { session }
15020 | NodeRequest::Stop { session, .. }
15021 | NodeRequest::Remove { session }
15022 | NodeRequest::ResolveInteraction { session, .. }
15023 | NodeRequest::SetSessionMode { session, .. }
15024 | NodeRequest::SetSessionConfigOption { session, .. }
15025 | NodeRequest::SetSessionModel { session, .. }
15026 | NodeRequest::DiscoverHistory { session, .. }
15027 | NodeRequest::LoadHistory { session, .. }
15028 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15029 | NodeRequest::ExportContextPack { session } => shared
15030 .session_bindings
15031 .lock()
15032 .unwrap_or_else(|poisoned| poisoned.into_inner())
15033 .get(&session.session.instance_id)
15034 .filter(|binding| {
15035 binding.workspace_id == session.workspace_id
15036 && binding.generation == session.session.generation
15037 })
15038 .map_or(true, |binding| binding.bundle.is_some()),
15039 NodeRequest::RenameSessionRecord { record_id, .. }
15040 | NodeRequest::SetSessionTask { record_id, .. }
15041 | NodeRequest::ResumeSessionRecord { record_id, .. }
15042 | NodeRequest::ForgetSessionRecord { record_id } => shared
15043 .record(record_id)
15044 .map_or(true, |record| record.bundle.is_some()),
15045 NodeRequest::ArmHarnessMcpReservation { .. }
15046 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15047 | NodeRequest::ActivateHarnessMcpReservation { .. }
15048 | NodeRequest::AbortHarnessMcpReservation { .. }
15049 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15050 | NodeRequest::RejectHarnessMcpCall { .. }
15051 | NodeRequest::Snapshot
15052 | NodeRequest::Resync { .. }
15053 | NodeRequest::BeginDeliveryStage { .. }
15054 | NodeRequest::PutDeliveryBlobChunk { .. }
15055 | NodeRequest::CommitDeliveryStage { .. }
15056 | NodeRequest::AbortDeliveryStage { .. }
15057 | NodeRequest::BrowseHostDirectories { .. }
15058 | NodeRequest::InspectWorkspace { .. }
15059 | NodeRequest::ReadWorkspaceFile { .. }
15060 | NodeRequest::WriteWorkspaceFile { .. }
15061 | NodeRequest::CreateWorkspaceFile { .. }
15062 | NodeRequest::CreateWorkspaceDirectory { .. }
15063 | NodeRequest::ReadGitHistory { .. }
15064 | NodeRequest::ReadGitDiff { .. }
15065 | NodeRequest::AcquireController { .. }
15066 | NodeRequest::ReleaseController
15067 | NodeRequest::Spawn { .. }
15068 | NodeRequest::SpawnWithOptions { .. }
15069 | NodeRequest::SpawnSpec { .. }
15070 | NodeRequest::RegisterWorkspace { .. }
15071 | NodeRequest::CreateStandaloneWorkspace { .. }
15072 | NodeRequest::UnregisterWorkspace { .. }
15073 | NodeRequest::CreateWorktree { .. }
15074 | NodeRequest::RemoveWorktree { .. }
15075 | NodeRequest::SpawnManagedWorktree { .. }
15076 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15077 | NodeRequest::CleanupManagedWorktree { .. }
15078 | NodeRequest::IndexProviderSession { .. }
15079 | NodeRequest::IndexNativeSession { .. }
15080 | NodeRequest::CatalogNativeSessions { .. }
15081 | NodeRequest::PageNativeSessions { .. }
15082 | NodeRequest::PreviewNativeSession { .. }
15083 | NodeRequest::PreviewSessionRecord { .. }
15084 | NodeRequest::ForgetContextPack { .. }
15085 | NodeRequest::ResolveDurableContextPack { .. }
15086 | NodeRequest::ReadContextPack { .. }
15087 | NodeRequest::Shutdown => false,
15088 }
15089}
15090
15091fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15092 if request.requires_history_context_pack_capability() {
15093 return true;
15094 }
15095 if let Some(spec) = match request {
15096 NodeRequest::SpawnSpec { spec }
15097 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15098 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15099 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15100 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15101 _ => None,
15102 } {
15103 return shared
15104 .resolve_spawn_spec(spec)
15105 .map_or(true, |resolved| resolved.context_id.is_some());
15106 }
15107 match request {
15108 NodeRequest::Resume { session, .. }
15109 | NodeRequest::Prompt { session, .. }
15110 | NodeRequest::Paste { session, .. }
15111 | NodeRequest::Input { session, .. }
15112 | NodeRequest::TerminalBytes { session, .. }
15113 | NodeRequest::TerminalControl { session, .. }
15114 | NodeRequest::Resize { session, .. }
15115 | NodeRequest::Interrupt { session }
15116 | NodeRequest::Stop { session, .. }
15117 | NodeRequest::Remove { session }
15118 | NodeRequest::ResolveInteraction { session, .. }
15119 | NodeRequest::SetSessionMode { session, .. }
15120 | NodeRequest::SetSessionConfigOption { session, .. }
15121 | NodeRequest::SetSessionModel { session, .. } => shared
15122 .session_bindings
15123 .lock()
15124 .unwrap_or_else(|poisoned| poisoned.into_inner())
15125 .get(&session.session.instance_id)
15126 .filter(|binding| {
15127 binding.workspace_id == session.workspace_id
15128 && binding.generation == session.session.generation
15129 })
15130 .map_or(true, |binding| binding.context.is_some()),
15131 NodeRequest::RenameSessionRecord { record_id, .. }
15132 | NodeRequest::SetSessionTask { record_id, .. }
15133 | NodeRequest::ResumeSessionRecord { record_id, .. }
15134 | NodeRequest::ForgetSessionRecord { record_id } => shared
15135 .record(record_id)
15136 .map_or(true, |record| record.context.is_some()),
15137 NodeRequest::ArmHarnessMcpReservation { .. }
15138 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15139 | NodeRequest::ActivateHarnessMcpReservation { .. }
15140 | NodeRequest::AbortHarnessMcpReservation { .. }
15141 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15142 | NodeRequest::RejectHarnessMcpCall { .. }
15143 | NodeRequest::Snapshot
15144 | NodeRequest::Resync { .. }
15145 | NodeRequest::BeginDeliveryStage { .. }
15146 | NodeRequest::PutDeliveryBlobChunk { .. }
15147 | NodeRequest::CommitDeliveryStage { .. }
15148 | NodeRequest::AbortDeliveryStage { .. }
15149 | NodeRequest::BrowseHostDirectories { .. }
15150 | NodeRequest::InspectWorkspace { .. }
15151 | NodeRequest::ReadWorkspaceFile { .. }
15152 | NodeRequest::WriteWorkspaceFile { .. }
15153 | NodeRequest::CreateWorkspaceFile { .. }
15154 | NodeRequest::CreateWorkspaceDirectory { .. }
15155 | NodeRequest::ReadGitHistory { .. }
15156 | NodeRequest::ReadGitDiff { .. }
15157 | NodeRequest::AcquireController { .. }
15158 | NodeRequest::ReleaseController
15159 | NodeRequest::Spawn { .. }
15160 | NodeRequest::SpawnWithOptions { .. }
15161 | NodeRequest::SpawnSpec { .. }
15162 | NodeRequest::RegisterWorkspace { .. }
15163 | NodeRequest::CreateStandaloneWorkspace { .. }
15164 | NodeRequest::UnregisterWorkspace { .. }
15165 | NodeRequest::CreateWorktree { .. }
15166 | NodeRequest::RemoveWorktree { .. }
15167 | NodeRequest::SpawnManagedWorktree { .. }
15168 | NodeRequest::SpawnManagedWorktreeV2 { .. }
15169 | NodeRequest::CleanupManagedWorktree { .. }
15170 | NodeRequest::IndexProviderSession { .. }
15171 | NodeRequest::IndexNativeSession { .. }
15172 | NodeRequest::CatalogNativeSessions { .. }
15173 | NodeRequest::PageNativeSessions { .. }
15174 | NodeRequest::PreviewNativeSession { .. }
15175 | NodeRequest::PreviewSessionRecord { .. }
15176 | NodeRequest::DiscoverHistory { .. }
15177 | NodeRequest::LoadHistory { .. }
15178 | NodeRequest::ExportContextPackForSessionRecord { .. }
15179 | NodeRequest::ExportContextPack { .. }
15180 | NodeRequest::ForgetContextPack { .. }
15181 | NodeRequest::ResolveDurableContextPack { .. }
15182 | NodeRequest::ReadContextPack { .. }
15183 | NodeRequest::Shutdown => false,
15184 }
15185}
15186
15187fn request_requires_open_provider_ids_with(
15188 request: &NodeRequest,
15189 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15190 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15191) -> bool {
15192 match request {
15193 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15194 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15195 NodeRequest::CatalogNativeSessions { route, .. }
15196 | NodeRequest::PageNativeSessions { route, .. } => {
15197 !provider_id_is_legacy(&route.provider)
15198 }
15199 NodeRequest::PreviewNativeSession { selection, .. }
15200 | NodeRequest::IndexNativeSession { selection, .. } => {
15201 !provider_id_is_legacy(&selection.route.provider)
15202 }
15203 NodeRequest::SpawnSpec { .. }
15204 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15205 NodeRequest::SpawnManagedWorktree { .. }
15206 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15207 NodeRequest::Resume { session, .. }
15208 | NodeRequest::Prompt { session, .. }
15209 | NodeRequest::Paste { session, .. }
15210 | NodeRequest::Input { session, .. }
15211 | NodeRequest::TerminalBytes { session, .. }
15212 | NodeRequest::TerminalControl { session, .. }
15213 | NodeRequest::Resize { session, .. }
15214 | NodeRequest::Interrupt { session }
15215 | NodeRequest::Stop { session, .. }
15216 | NodeRequest::Remove { session }
15217 | NodeRequest::ResolveInteraction { session, .. }
15218 | NodeRequest::SetSessionMode { session, .. }
15219 | NodeRequest::SetSessionConfigOption { session, .. }
15220 | NodeRequest::SetSessionModel { session, .. }
15221 | NodeRequest::DiscoverHistory { session, .. }
15222 | NodeRequest::LoadHistory { session, .. }
15223 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15224 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15225 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15226 NodeRequest::RenameSessionRecord { record_id, .. }
15227 | NodeRequest::SetSessionTask { record_id, .. }
15228 | NodeRequest::ResumeSessionRecord { record_id, .. }
15229 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15230 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15231 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15232 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15233 NodeRequest::ArmHarnessMcpReservation { .. }
15234 | NodeRequest::ActivateHarnessMcpReservation { .. }
15235 | NodeRequest::AbortHarnessMcpReservation { .. }
15236 | NodeRequest::PutHarnessMcpReplyChunk { .. }
15237 | NodeRequest::RejectHarnessMcpCall { .. }
15238 | NodeRequest::Snapshot
15239 | NodeRequest::Resync { .. }
15240 | NodeRequest::BeginDeliveryStage { .. }
15241 | NodeRequest::PutDeliveryBlobChunk { .. }
15242 | NodeRequest::CommitDeliveryStage { .. }
15243 | NodeRequest::AbortDeliveryStage { .. }
15244 | NodeRequest::BrowseHostDirectories { .. }
15245 | NodeRequest::InspectWorkspace { .. }
15246 | NodeRequest::ReadWorkspaceFile { .. }
15247 | NodeRequest::WriteWorkspaceFile { .. }
15248 | NodeRequest::CreateWorkspaceFile { .. }
15249 | NodeRequest::CreateWorkspaceDirectory { .. }
15250 | NodeRequest::ReadGitHistory { .. }
15251 | NodeRequest::ReadGitDiff { .. }
15252 | NodeRequest::AcquireController { .. }
15253 | NodeRequest::ReleaseController
15254 | NodeRequest::RegisterWorkspace { .. }
15255 | NodeRequest::CreateStandaloneWorkspace { .. }
15256 | NodeRequest::UnregisterWorkspace { .. }
15257 | NodeRequest::CreateWorktree { .. }
15258 | NodeRequest::RemoveWorktree { .. }
15259 | NodeRequest::CleanupManagedWorktree { .. }
15260 | NodeRequest::Shutdown => false,
15261 NodeRequest::ForgetContextPack { .. } => true,
15262 NodeRequest::ResolveDurableContextPack { .. }
15268 | NodeRequest::ReadContextPack { .. } => true,
15269 }
15270}
15271
15272fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15273 snapshot.enabled_providers.retain(provider_id_is_legacy);
15274 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15275 snapshot
15276 .provider_runtime_statuses
15277 .as_slice()
15278 .iter()
15279 .filter(|status| provider_id_is_legacy(status.provider()))
15280 .cloned(),
15281 )
15282 .expect("a filtered provider runtime projection remains valid");
15283 snapshot
15284 .session_records
15285 .retain(|record| provider_id_is_legacy(&record.provider));
15286 for workspace in &mut snapshot.workspaces {
15287 workspace
15288 .sessions
15289 .retain(|session| provider_id_is_legacy(&session.agent_id));
15290 }
15291}
15292
15293fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15294 for record in &mut snapshot.session_records {
15295 record.environment_profile = None;
15296 }
15297 if let Some(profiles) = snapshot
15298 .launch_inventory
15299 .as_mut()
15300 .and_then(|inventory| inventory.spawn_profiles.as_mut())
15301 {
15302 for profile in profiles {
15303 profile.environment_profile = None;
15304 }
15305 }
15306}
15307
15308fn project_event_without_child_environment_profile(
15309 mut envelope: NodeEventEnvelope,
15310) -> NodeEventEnvelope {
15311 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15312 record.environment_profile = None;
15313 }
15314 envelope
15315}
15316
15317fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15318 let contains_environment_profile = match reply.result.as_ref() {
15319 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15320 receipt.environment_profile.is_some()
15321 }
15322 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15323 receipt.spawn.environment_profile.is_some()
15324 }
15325 _ => false,
15326 };
15327 if contains_environment_profile {
15328 reply.result = Err(failure(
15329 NodeFailureCode::UnsupportedCapability,
15330 "child environment profiles were not negotiated",
15331 ));
15332 return;
15333 }
15334 let Ok(response) = reply.result.as_mut() else {
15335 return;
15336 };
15337 match response {
15338 NodeResponse::Snapshot { snapshot, .. } => {
15339 clear_snapshot_child_environment_profiles(snapshot);
15340 }
15341 NodeResponse::Resync { snapshot, events, .. } => {
15342 clear_snapshot_child_environment_profiles(snapshot);
15343 for event in events {
15344 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15345 record.environment_profile = None;
15346 }
15347 }
15348 }
15349 NodeResponse::SessionRecordUpdated { record }
15350 | NodeResponse::ProviderSessionIndexed { record }
15351 | NodeResponse::NativeSessionIndexed { record, .. }
15352 | NodeResponse::SessionRecordResumed { record, .. } => {
15353 record.environment_profile = None;
15354 }
15355 NodeResponse::Armed { .. }
15356 | NodeResponse::Spawned { .. }
15357 | NodeResponse::Activated { .. }
15358 | NodeResponse::Aborted { .. }
15359 | NodeResponse::ReplyChunkAccepted { .. }
15360 | NodeResponse::CallRejected { .. }
15361 | NodeResponse::DeliveryStageBegun { .. }
15362 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15363 | NodeResponse::DeliveryCommitted { .. }
15364 | NodeResponse::DeliveryStageAborted { .. }
15365 | NodeResponse::WorkspaceInspected { .. }
15366 | NodeResponse::HostDirectoriesBrowsed { .. }
15367 | NodeResponse::WorkspaceFileRead { .. }
15368 | NodeResponse::WorkspaceFileWritten { .. }
15369 | NodeResponse::WorkspaceFileCreated { .. }
15370 | NodeResponse::WorkspaceDirectoryCreated { .. }
15371 | NodeResponse::GitHistoryRead { .. }
15372 | NodeResponse::GitDiffRead { .. }
15373 | NodeResponse::Controller { .. }
15374 | NodeResponse::SpawnAccepted { .. }
15375 | NodeResponse::SpawnSpecAccepted { .. }
15376 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15377 | NodeResponse::ManagedWorktreeCleanup { .. }
15378 | NodeResponse::NativeSessionsCataloged { .. }
15379 | NodeResponse::NativeSessionsPaged { .. }
15380 | NodeResponse::NativeSessionPreviewed { .. }
15381 | NodeResponse::SessionRecordPreviewed { .. }
15382 | NodeResponse::HistoryDiscovered { .. }
15383 | NodeResponse::HistoryLoaded { .. }
15384 | NodeResponse::ContextPackForSessionRecordExported { .. }
15385 | NodeResponse::ContextPackExported { .. }
15386 | NodeResponse::ContextPackForgotten { .. }
15387 | NodeResponse::DurableContextPackResolved { .. }
15388 | NodeResponse::ContextPackBytesRead { .. }
15389 | NodeResponse::SessionRecordForgotten { .. }
15390 | NodeResponse::WorkspaceRegistered { .. }
15391 | NodeResponse::StandaloneWorkspaceCreated { .. }
15392 | NodeResponse::WorkspaceUnregistered { .. }
15393 | NodeResponse::WorktreeCreated { .. }
15394 | NodeResponse::WorktreeRemoved { .. }
15395 | NodeResponse::Accepted
15396 | NodeResponse::ShuttingDown => {}
15397 }
15398}
15399
15400fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15401 for record in &mut snapshot.session_records {
15402 record.bundle = None;
15403 }
15404}
15405
15406fn project_event_without_session_bundle(
15407 mut envelope: NodeEventEnvelope,
15408) -> NodeEventEnvelope {
15409 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15410 record.bundle = None;
15411 }
15412 envelope
15413}
15414
15415fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15416 let contains_bundle = match reply.result.as_ref() {
15417 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15418 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15419 receipt.spawn.bundle.is_some()
15420 }
15421 _ => false,
15422 };
15423 if contains_bundle {
15424 reply.result = Err(failure(
15425 NodeFailureCode::UnsupportedCapability,
15426 "session bundles were not negotiated",
15427 ));
15428 return;
15429 }
15430 let Ok(response) = reply.result.as_mut() else {
15431 return;
15432 };
15433 match response {
15434 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15435 NodeResponse::Resync { snapshot, events, .. } => {
15436 clear_snapshot_session_bundles(snapshot);
15437 for event in events {
15438 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15439 record.bundle = None;
15440 }
15441 }
15442 }
15443 NodeResponse::SessionRecordUpdated { record }
15444 | NodeResponse::ProviderSessionIndexed { record }
15445 | NodeResponse::NativeSessionIndexed { record, .. }
15446 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15447 NodeResponse::Armed { .. }
15448 | NodeResponse::Spawned { .. }
15449 | NodeResponse::Activated { .. }
15450 | NodeResponse::Aborted { .. }
15451 | NodeResponse::ReplyChunkAccepted { .. }
15452 | NodeResponse::CallRejected { .. }
15453 | NodeResponse::DeliveryStageBegun { .. }
15454 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15455 | NodeResponse::DeliveryCommitted { .. }
15456 | NodeResponse::DeliveryStageAborted { .. }
15457 | NodeResponse::WorkspaceInspected { .. }
15458 | NodeResponse::HostDirectoriesBrowsed { .. }
15459 | NodeResponse::WorkspaceFileRead { .. }
15460 | NodeResponse::WorkspaceFileWritten { .. }
15461 | NodeResponse::WorkspaceFileCreated { .. }
15462 | NodeResponse::WorkspaceDirectoryCreated { .. }
15463 | NodeResponse::GitHistoryRead { .. }
15464 | NodeResponse::GitDiffRead { .. }
15465 | NodeResponse::Controller { .. }
15466 | NodeResponse::SpawnAccepted { .. }
15467 | NodeResponse::SpawnSpecAccepted { .. }
15468 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15469 | NodeResponse::ManagedWorktreeCleanup { .. }
15470 | NodeResponse::NativeSessionsCataloged { .. }
15471 | NodeResponse::NativeSessionsPaged { .. }
15472 | NodeResponse::NativeSessionPreviewed { .. }
15473 | NodeResponse::SessionRecordPreviewed { .. }
15474 | NodeResponse::HistoryDiscovered { .. }
15475 | NodeResponse::HistoryLoaded { .. }
15476 | NodeResponse::ContextPackForSessionRecordExported { .. }
15477 | NodeResponse::ContextPackExported { .. }
15478 | NodeResponse::ContextPackForgotten { .. }
15479 | NodeResponse::DurableContextPackResolved { .. }
15480 | NodeResponse::ContextPackBytesRead { .. }
15481 | NodeResponse::SessionRecordForgotten { .. }
15482 | NodeResponse::WorkspaceRegistered { .. }
15483 | NodeResponse::StandaloneWorkspaceCreated { .. }
15484 | NodeResponse::WorkspaceUnregistered { .. }
15485 | NodeResponse::WorktreeCreated { .. }
15486 | NodeResponse::WorktreeRemoved { .. }
15487 | NodeResponse::Accepted
15488 | NodeResponse::ShuttingDown => {}
15489 }
15490}
15491
15492fn project_snapshot_history_for_wire(
15493 snapshot: &mut NodeSnapshot,
15494 include_history_metadata: bool,
15495) {
15496 for workspace in &mut snapshot.workspaces {
15497 for session in &mut workspace.sessions {
15498 if include_history_metadata {
15499 session.history.loaded = None;
15500 } else {
15501 session.history = Default::default();
15502 }
15503 }
15504 }
15505}
15506
15507fn project_event_history_for_wire(
15508 mut envelope: NodeEventEnvelope,
15509 include_history_metadata: bool,
15510) -> NodeEventEnvelope {
15511 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15512 for session in &mut workspace.sessions {
15513 if include_history_metadata {
15514 session.history.loaded = None;
15515 } else {
15516 session.history = Default::default();
15517 }
15518 }
15519 }
15520 envelope
15521}
15522
15523fn project_response_history_for_wire(
15524 reply: &mut ResponseEnvelope,
15525 include_history_metadata: bool,
15526) {
15527 let Ok(response) = reply.result.as_mut() else {
15528 return;
15529 };
15530 match response {
15531 NodeResponse::Snapshot { snapshot, .. } => {
15532 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15533 }
15534 NodeResponse::Resync {
15535 snapshot, events, ..
15536 } => {
15537 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15538 for event in events {
15539 *event = project_event_history_for_wire(
15540 event.clone(),
15541 include_history_metadata,
15542 );
15543 }
15544 }
15545 NodeResponse::WorkspaceRegistered { workspace }
15546 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15547 | NodeResponse::WorktreeCreated { workspace, .. } => {
15548 for session in &mut workspace.sessions {
15549 if include_history_metadata {
15550 session.history.loaded = None;
15551 } else {
15552 session.history = Default::default();
15553 }
15554 }
15555 }
15556 _ => {}
15557 }
15558}
15559
15560fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15561 for record in &mut snapshot.session_records {
15562 record.context_id = None;
15563 record.context = None;
15564 record.exported_context = None;
15565 }
15566}
15567
15568fn project_event_without_context_pack(
15569 mut envelope: NodeEventEnvelope,
15570) -> NodeEventEnvelope {
15571 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15572 record.context_id = None;
15573 record.context = None;
15574 record.exported_context = None;
15575 }
15576 envelope
15577}
15578
15579fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15580 let contains_context = match reply.result.as_ref() {
15581 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15582 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15583 receipt.spawn.context.is_some()
15584 }
15585 Ok(NodeResponse::HistoryDiscovered { .. })
15586 | Ok(NodeResponse::HistoryLoaded { .. })
15587 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15588 | Ok(NodeResponse::ContextPackExported { .. })
15589 | Ok(NodeResponse::ContextPackForgotten { .. })
15590 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15591 _ => false,
15592 };
15593 if contains_context {
15594 reply.result = Err(failure(
15595 NodeFailureCode::UnsupportedCapability,
15596 "history context packs were not negotiated",
15597 ));
15598 return;
15599 }
15600 let Ok(response) = reply.result.as_mut() else {
15601 return;
15602 };
15603 match response {
15604 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15605 NodeResponse::Resync {
15606 snapshot, events, ..
15607 } => {
15608 clear_snapshot_context_packs(snapshot);
15609 for event in events {
15610 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15611 record.context_id = None;
15612 record.context = None;
15613 record.exported_context = None;
15614 }
15615 }
15616 }
15617 NodeResponse::SessionRecordUpdated { record }
15618 | NodeResponse::ProviderSessionIndexed { record }
15619 | NodeResponse::NativeSessionIndexed { record, .. }
15620 | NodeResponse::SessionRecordResumed { record, .. } => {
15621 record.context_id = None;
15622 record.context = None;
15623 record.exported_context = None;
15624 }
15625 _ => {}
15626 }
15627}
15628
15629fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15630 let contains_open_record = match reply.result.as_ref() {
15631 Ok(NodeResponse::SessionRecordUpdated { record })
15632 | Ok(NodeResponse::ProviderSessionIndexed { record })
15633 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15634 !provider_id_is_legacy(&record.provider)
15635 }
15636 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15637 !provider_id_is_legacy(&selection.route.provider)
15638 || !provider_id_is_legacy(&record.provider)
15639 }
15640 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15641 !provider_id_is_legacy(&receipt.provider)
15642 }
15643 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15644 !provider_id_is_legacy(&receipt.spawn.provider)
15645 }
15646 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15647 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15648 !provider_id_is_legacy(&route.provider)
15649 }
15650 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15651 !provider_id_is_legacy(&selection.route.provider)
15652 }
15653 _ => false,
15654 };
15655 if contains_open_record {
15656 reply.result = Err(failure(
15657 NodeFailureCode::UnsupportedCapability,
15658 "open provider IDs were not negotiated",
15659 ));
15660 return;
15661 }
15662 let Ok(response) = reply.result.as_mut() else {
15663 return;
15664 };
15665 match response {
15666 NodeResponse::Snapshot { snapshot, .. } => {
15667 project_snapshot_legacy_provider_ids(snapshot);
15668 }
15669 NodeResponse::Resync { snapshot, events, .. } => {
15670 project_snapshot_legacy_provider_ids(snapshot);
15671 *events = std::mem::take(events)
15672 .into_iter()
15673 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15674 .collect();
15675 }
15676 NodeResponse::WorkspaceRegistered { workspace }
15677 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15678 | NodeResponse::WorktreeCreated { workspace, .. } => {
15679 workspace
15680 .sessions
15681 .retain(|session| provider_id_is_legacy(&session.agent_id));
15682 }
15683 NodeResponse::Armed { .. }
15684 | NodeResponse::Spawned { .. }
15685 | NodeResponse::Activated { .. }
15686 | NodeResponse::Aborted { .. }
15687 | NodeResponse::ReplyChunkAccepted { .. }
15688 | NodeResponse::CallRejected { .. }
15689 | NodeResponse::DeliveryStageBegun { .. }
15690 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15691 | NodeResponse::DeliveryCommitted { .. }
15692 | NodeResponse::DeliveryStageAborted { .. }
15693 | NodeResponse::WorkspaceInspected { .. }
15694 | NodeResponse::HostDirectoriesBrowsed { .. }
15695 | NodeResponse::WorkspaceFileRead { .. }
15696 | NodeResponse::WorkspaceFileWritten { .. }
15697 | NodeResponse::WorkspaceFileCreated { .. }
15698 | NodeResponse::WorkspaceDirectoryCreated { .. }
15699 | NodeResponse::GitHistoryRead { .. }
15700 | NodeResponse::GitDiffRead { .. }
15701 | NodeResponse::Controller { .. }
15702 | NodeResponse::SpawnAccepted { .. }
15703 | NodeResponse::SpawnSpecAccepted { .. }
15704 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15705 | NodeResponse::ManagedWorktreeCleanup { .. }
15706 | NodeResponse::NativeSessionsCataloged { .. }
15707 | NodeResponse::NativeSessionsPaged { .. }
15708 | NodeResponse::NativeSessionPreviewed { .. }
15709 | NodeResponse::SessionRecordPreviewed { .. }
15710 | NodeResponse::HistoryDiscovered { .. }
15711 | NodeResponse::HistoryLoaded { .. }
15712 | NodeResponse::ContextPackForSessionRecordExported { .. }
15713 | NodeResponse::ContextPackExported { .. }
15714 | NodeResponse::ContextPackForgotten { .. }
15715 | NodeResponse::DurableContextPackResolved { .. }
15716 | NodeResponse::ContextPackBytesRead { .. }
15717 | NodeResponse::SessionRecordUpdated { .. }
15718 | NodeResponse::ProviderSessionIndexed { .. }
15719 | NodeResponse::NativeSessionIndexed { .. }
15720 | NodeResponse::SessionRecordResumed { .. }
15721 | NodeResponse::SessionRecordForgotten { .. }
15722 | NodeResponse::WorkspaceUnregistered { .. }
15723 | NodeResponse::WorktreeRemoved { .. }
15724 | NodeResponse::Accepted
15725 | NodeResponse::ShuttingDown => {}
15726 }
15727}
15728
15729fn project_event_legacy_provider_ids(
15730 shared: &NodeShared,
15731 mut envelope: NodeEventEnvelope,
15732) -> Option<NodeEventEnvelope> {
15733 let include = match &mut envelope.event {
15734 NodeEvent::HarnessMcpReadCall { .. } => true,
15735 NodeEvent::Control { address, .. }
15736 | NodeEvent::AgentStream { address, .. }
15737 | NodeEvent::TerminalFrame { address, .. } => shared
15738 .validate_address(address)
15739 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15740 NodeEvent::WorkspaceAdded { workspace } => {
15741 workspace
15742 .sessions
15743 .retain(|session| provider_id_is_legacy(&session.agent_id));
15744 true
15745 }
15746 NodeEvent::SessionRecordUpserted { record } => {
15747 provider_id_is_legacy(&record.provider)
15748 }
15749 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15750 NodeEvent::SessionRecordRemoved { .. } => shared
15751 .history
15752 .lock()
15753 .unwrap_or_else(|poisoned| poisoned.into_inner())
15754 .removed_record_providers
15755 .get(&envelope.sequence)
15756 .is_some_and(provider_id_is_legacy),
15757 NodeEvent::ControllerChanged { .. }
15758 | NodeEvent::ManagedWorktreeUpserted { .. }
15759 | NodeEvent::ManagedWorktreeRemoved { .. }
15760 | NodeEvent::WorkspaceRemoved { .. }
15761 | NodeEvent::ResyncRequired { .. } => true,
15762 };
15763 include.then_some(envelope)
15764}
15765
15766fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15767 let Ok(response) = reply.result.as_mut() else {
15768 return;
15769 };
15770 match response {
15771 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15772 snapshot.provider_runtime_statuses.clear();
15773 }
15774 NodeResponse::Armed { .. }
15775 | NodeResponse::Spawned { .. }
15776 | NodeResponse::Activated { .. }
15777 | NodeResponse::Aborted { .. }
15778 | NodeResponse::ReplyChunkAccepted { .. }
15779 | NodeResponse::CallRejected { .. }
15780 | NodeResponse::DeliveryStageBegun { .. }
15781 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15782 | NodeResponse::DeliveryCommitted { .. }
15783 | NodeResponse::DeliveryStageAborted { .. }
15784 | NodeResponse::WorkspaceInspected { .. }
15785 | NodeResponse::HostDirectoriesBrowsed { .. }
15786 | NodeResponse::WorkspaceFileRead { .. }
15787 | NodeResponse::WorkspaceFileWritten { .. }
15788 | NodeResponse::WorkspaceFileCreated { .. }
15789 | NodeResponse::WorkspaceDirectoryCreated { .. }
15790 | NodeResponse::GitHistoryRead { .. }
15791 | NodeResponse::GitDiffRead { .. }
15792 | NodeResponse::Controller { .. }
15793 | NodeResponse::SpawnAccepted { .. }
15794 | NodeResponse::SpawnSpecAccepted { .. }
15795 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15796 | NodeResponse::ManagedWorktreeCleanup { .. }
15797 | NodeResponse::NativeSessionsCataloged { .. }
15798 | NodeResponse::NativeSessionsPaged { .. }
15799 | NodeResponse::NativeSessionPreviewed { .. }
15800 | NodeResponse::SessionRecordPreviewed { .. }
15801 | NodeResponse::HistoryDiscovered { .. }
15802 | NodeResponse::HistoryLoaded { .. }
15803 | NodeResponse::ContextPackForSessionRecordExported { .. }
15804 | NodeResponse::ContextPackExported { .. }
15805 | NodeResponse::ContextPackForgotten { .. }
15806 | NodeResponse::DurableContextPackResolved { .. }
15807 | NodeResponse::ContextPackBytesRead { .. }
15808 | NodeResponse::SessionRecordUpdated { .. }
15809 | NodeResponse::ProviderSessionIndexed { .. }
15810 | NodeResponse::NativeSessionIndexed { .. }
15811 | NodeResponse::SessionRecordResumed { .. }
15812 | NodeResponse::SessionRecordForgotten { .. }
15813 | NodeResponse::WorkspaceRegistered { .. }
15814 | NodeResponse::StandaloneWorkspaceCreated { .. }
15815 | NodeResponse::WorkspaceUnregistered { .. }
15816 | NodeResponse::WorktreeCreated { .. }
15817 | NodeResponse::WorktreeRemoved { .. }
15818 | NodeResponse::Accepted
15819 | NodeResponse::ShuttingDown => {}
15820 }
15821}
15822
15823struct ControllerReleaseGuard {
15824 shared: Arc<NodeShared>,
15825 connection_id: u64,
15826}
15827
15828impl Drop for ControllerReleaseGuard {
15829 fn drop(&mut self) {
15830 self.shared.release_controller(self.connection_id);
15831 }
15832}
15833
15834struct AbortTaskOnDrop(AbortHandle);
15835
15836impl Drop for AbortTaskOnDrop {
15837 fn drop(&mut self) {
15838 self.0.abort();
15839 }
15840}
15841
15842fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15847 std::env::var(name)
15848 .ok()
15849 .and_then(|value| value.parse::<u64>().ok())
15850 .filter(|value| *value > 0)
15851 .unwrap_or(default)
15852 .min(max)
15853}
15854
15855fn workspace_inspection_time_budget_ms() -> u64 {
15856 env_config_bounded(
15857 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15858 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15859 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15860 )
15861}
15862
15863fn workspace_inspection_entry_cap() -> usize {
15864 env_config_bounded(
15865 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15866 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15867 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15868 ) as usize
15869}
15870
15871struct WorkspaceWalkBudget {
15877 deadline: Instant,
15878 entry_cap: usize,
15879 entries_visited: u64,
15880 time_budget_exceeded: bool,
15881 entry_cap_exceeded: bool,
15882}
15883
15884impl WorkspaceWalkBudget {
15885 fn new(deadline: Instant, entry_cap: usize) -> Self {
15886 Self {
15887 deadline,
15888 entry_cap,
15889 entries_visited: 0,
15890 time_budget_exceeded: false,
15891 entry_cap_exceeded: false,
15892 }
15893 }
15894
15895 fn is_exhausted(&self) -> bool {
15896 self.time_budget_exceeded || self.entry_cap_exceeded
15897 }
15898
15899 fn record_visit(&mut self) -> bool {
15902 self.entries_visited += 1;
15903 if self.entries_visited as usize >= self.entry_cap {
15904 self.entry_cap_exceeded = true;
15905 }
15906 if Instant::now() >= self.deadline {
15907 self.time_budget_exceeded = true;
15908 }
15909 self.is_exhausted()
15910 }
15911}
15912
15913fn collect_workspace_entries(
15914 root: &Path,
15915 deadline: Instant,
15916 entry_cap: usize,
15917) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15918 let mut entries = Vec::new();
15919 let mut truncated = false;
15920 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15921 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15922 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15926 (entries, truncated, budget)
15927}
15928
15929fn workspace_inspection_truncation(
15933 walk_budget: &WorkspaceWalkBudget,
15934 git_time_budget_exceeded: bool,
15935 elapsed: Duration,
15936) -> Option<WorkspaceInspectionTruncationV1> {
15937 if !walk_budget.time_budget_exceeded
15938 && !walk_budget.entry_cap_exceeded
15939 && !git_time_budget_exceeded
15940 {
15941 return None;
15942 }
15943 Some(WorkspaceInspectionTruncationV1 {
15944 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15945 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15946 git_time_budget_exceeded,
15947 entries_visited: walk_budget.entries_visited,
15948 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15949 })
15950}
15951
15952fn walk_workspace_directory(
15953 directory: &Path,
15954 relative_directory: Option<&RepositoryPath>,
15955 depth: usize,
15956 entries: &mut Vec<WorkspaceEntry>,
15957 truncated: &mut bool,
15958 budget: &mut WorkspaceWalkBudget,
15959) {
15960 if budget.is_exhausted() {
15961 *truncated = true;
15962 return;
15963 }
15964 let read_dir = match std::fs::read_dir(directory) {
15965 Ok(read_dir) => read_dir,
15966 Err(_) => {
15967 *truncated = true;
15968 return;
15969 }
15970 };
15971 let mut children = Vec::new();
15972 for child in read_dir {
15973 match child {
15974 Ok(child) => children.push(child),
15975 Err(_) => *truncated = true,
15976 }
15977 }
15978 children.sort_by_key(|child| child.file_name());
15979 for child in children {
15980 if budget.record_visit() {
15981 *truncated = true;
15982 return;
15983 }
15984 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15985 *truncated = true;
15986 return;
15987 }
15988 let file_type = match child.file_type() {
15989 Ok(file_type) => file_type,
15990 Err(_) => {
15991 *truncated = true;
15992 continue;
15993 }
15994 };
15995 if file_type.is_symlink() {
15996 continue;
15997 }
15998 let name = match child.file_name().into_string() {
15999 Ok(name) => name,
16000 Err(_) => {
16001 *truncated = true;
16002 continue;
16003 }
16004 };
16005 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
16006 continue;
16007 }
16008 let relative_path = match relative_directory {
16009 None => windows_repository_path(name),
16010 Some(parent) => parent
16011 .as_utf8()
16012 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
16013 };
16014 let Some(relative_path) = relative_path else {
16015 *truncated = true;
16016 continue;
16017 };
16018 let kind = if file_type.is_dir() {
16019 WorkspaceEntryKind::Directory
16020 } else if file_type.is_file() {
16021 WorkspaceEntryKind::File
16022 } else {
16023 continue;
16024 };
16025 entries.push(WorkspaceEntry {
16026 relative_path: relative_path.clone(),
16027 kind,
16028 });
16029 if file_type.is_dir() {
16030 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
16031 walk_workspace_directory(
16032 &child.path(),
16033 Some(&relative_path),
16034 depth + 1,
16035 entries,
16036 truncated,
16037 budget,
16038 );
16039 } else {
16040 *truncated = true;
16041 }
16042 }
16043 }
16044}
16045
16046fn windows_repository_path(value: String) -> Option<RepositoryPath> {
16047 if value.contains('\\') {
16048 return None;
16049 }
16050 RepositoryPath::utf8(value).ok()
16051}
16052
16053fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
16054 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
16055}
16056
16057fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
16066 if name.eq_ignore_ascii_case(".git")
16067 || name.eq_ignore_ascii_case("target")
16068 || name.eq_ignore_ascii_case("node_modules")
16069 {
16070 return true;
16071 }
16072 path.join("CACHEDIR.TAG").is_file()
16073}
16074
16075fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16076 matches!(
16077 status,
16078 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16079 )
16080}
16081
16082#[derive(Clone, Debug, Eq, PartialEq)]
16083enum ContextPackRepositoryHead {
16084 NotRepository,
16085 Unborn,
16086 Commit(String),
16087}
16088
16089#[derive(Clone, Debug, Eq, PartialEq)]
16090struct ContextPackRepositoryObservation {
16091 head: ContextPackRepositoryHead,
16092 git: GitSnapshot,
16093 files: Vec<ContextPackRepositoryFileSource>,
16094}
16095
16096fn stable_context_pack_repository(
16097 first: ContextPackRepositoryObservation,
16098 second: ContextPackRepositoryObservation,
16099) -> Result<ContextPackRepository, NodeFailure> {
16100 if first != second {
16101 return Err(failure(
16102 NodeFailureCode::ContextPackMaterializationFailed,
16103 "context repository changed during bounded capture",
16104 ));
16105 }
16106 Ok(ContextPackRepository::from_sources(
16107 &second.git,
16108 second.files,
16109 ))
16110}
16111
16112fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16119 if Instant::now() < deadline {
16120 return false;
16121 }
16122 append_git_diagnostic(
16123 snapshot,
16124 &format!("{stage} skipped: workspace inspection time budget exhausted"),
16125 );
16126 true
16127}
16128
16129async fn run_git_bounded_with_deadline(
16138 root: &str,
16139 arguments: &[&str],
16140 output_limit: usize,
16141 deadline: Instant,
16142) -> io::Result<GitCommandOutput> {
16143 let remaining = deadline.checked_duration_since(Instant::now());
16144 let timeout_ms = remaining
16145 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16146 .unwrap_or(0)
16147 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16148 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16149}
16150
16151async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16158 let mut snapshot = GitSnapshot {
16159 is_repository: false,
16160 branch: None,
16161 status: Vec::new(),
16162 recent_commits: Vec::new(),
16163 worktrees: Vec::new(),
16164 managed_worktree: None,
16165 truncated: false,
16166 diagnostic: None,
16167 };
16168 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16169 return (snapshot, true);
16170 }
16171 let repository = match run_git_bounded_with_deadline(
16172 root,
16173 &["rev-parse", "--is-inside-work-tree"],
16174 4 * 1_024,
16175 deadline,
16176 )
16177 .await
16178 {
16179 Ok(output) => output,
16180 Err(error) => {
16181 append_git_diagnostic(
16182 &mut snapshot,
16183 &format!("git inspection unavailable: {error}"),
16184 );
16185 return (snapshot, false);
16186 }
16187 };
16188 snapshot.truncated |= repository.truncated;
16189 if repository.timed_out {
16190 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16191 return (snapshot, false);
16192 }
16193 if !repository.success
16194 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16195 {
16196 let stderr = String::from_utf8_lossy(&repository.stderr);
16197 if !stderr.to_ascii_lowercase().contains("not a git repository") {
16198 append_git_diagnostic(&mut snapshot, stderr.trim());
16199 }
16200 return (snapshot, false);
16201 }
16202 snapshot.is_repository = true;
16203
16204 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16205 return (snapshot, true);
16206 }
16207 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16208 Ok(output) => {
16209 snapshot.truncated |= output.truncated;
16210 if output.timed_out {
16211 append_git_diagnostic(&mut snapshot, "git branch query timed out");
16212 } else if output.success {
16213 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16214 if !branch.is_empty() {
16215 snapshot.branch = Some(truncate_git_field(
16216 &branch,
16217 MAX_REPOSITORY_PATH_BYTES,
16218 &mut snapshot.truncated,
16219 ));
16220 }
16221 }
16222 }
16223 Err(error) => append_git_diagnostic(
16224 &mut snapshot,
16225 &format!("git branch query failed: {error}"),
16226 ),
16227 }
16228 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16229 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16230 snapshot.truncated |= output.truncated;
16231 if output.success && !output.timed_out {
16232 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16233 if !head.is_empty() {
16234 snapshot.branch = Some(format!("detached:{head}"));
16235 }
16236 }
16237 }
16238 }
16239
16240 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16241 return (snapshot, true);
16242 }
16243 match run_git_bounded_with_deadline(
16244 root,
16245 &[
16246 "status",
16247 "--porcelain=v1",
16248 "-z",
16249 "--renames",
16250 "--untracked-files=normal",
16251 "--",
16252 ".",
16253 ],
16254 GIT_OUTPUT_MAX_BYTES,
16255 deadline,
16256 )
16257 .await
16258 {
16259 Ok(output) => {
16260 snapshot.truncated |= output.truncated;
16261 if output.timed_out {
16262 append_git_diagnostic(&mut snapshot, "git status timed out");
16263 } else if output.success {
16264 parse_git_status(&output.stdout, &mut snapshot);
16265 } else {
16266 append_git_diagnostic(
16267 &mut snapshot,
16268 String::from_utf8_lossy(&output.stderr).trim(),
16269 );
16270 }
16271 }
16272 Err(error) => append_git_diagnostic(
16273 &mut snapshot,
16274 &format!("git status failed: {error}"),
16275 ),
16276 }
16277
16278 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16279 return (snapshot, true);
16280 }
16281 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16282 match run_git_bounded_with_deadline(
16283 root,
16284 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16285 16 * 1_024,
16286 deadline,
16287 )
16288 .await
16289 {
16290 Ok(output) => {
16291 snapshot.truncated |= output.truncated;
16292 if output.timed_out {
16293 append_git_diagnostic(&mut snapshot, "git log timed out");
16294 } else if output.success {
16295 parse_git_commits(&output.stdout, &mut snapshot);
16296 } else {
16297 let stderr = String::from_utf8_lossy(&output.stderr);
16298 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16299 append_git_diagnostic(&mut snapshot, stderr.trim());
16300 }
16301 }
16302 }
16303 Err(error) => append_git_diagnostic(
16304 &mut snapshot,
16305 &format!("git log failed: {error}"),
16306 ),
16307 }
16308
16309 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16310 return (snapshot, true);
16311 }
16312 match list_git_worktrees_with_deadline(root, deadline).await {
16313 Ok(worktrees) => {
16314 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16315 }
16316 Err(error) => append_git_diagnostic(
16317 &mut snapshot,
16318 &format!("git worktree list failed: {}", error.message),
16319 ),
16320 }
16321 (snapshot, false)
16322}
16323
16324fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16325 let mut remaining = output;
16326 while !remaining.is_empty() {
16327 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16328 snapshot.truncated = true;
16329 break;
16330 }
16331 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16332 snapshot.truncated = true;
16333 break;
16334 };
16335 let record = &remaining[..record_end];
16336 remaining = &remaining[record_end + 1..];
16337 let has_previous_path = record
16338 .get(..2)
16339 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16340 let previous_path_bytes = if has_previous_path {
16341 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16342 snapshot.truncated = true;
16343 break;
16344 };
16345 let previous = &remaining[..previous_end];
16346 remaining = &remaining[previous_end + 1..];
16347 Some(previous)
16348 } else {
16349 None
16350 };
16351 if record.len() < 4
16352 || record[2] != b' '
16353 || !is_git_status_code(record[0])
16354 || !is_git_status_code(record[1])
16355 {
16356 snapshot.truncated = true;
16357 continue;
16358 }
16359 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16360 snapshot.truncated = true;
16361 continue;
16362 };
16363 let previous_path = match previous_path_bytes {
16364 Some(previous) => {
16365 let Some(previous) = windows_repository_path_from_bytes(previous) else {
16366 snapshot.truncated = true;
16367 continue;
16368 };
16369 Some(previous)
16370 }
16371 None => None,
16372 };
16373 snapshot.status.push(GitStatusEntry {
16374 index_status: (record[0] as char).to_string(),
16375 worktree_status: (record[1] as char).to_string(),
16376 path,
16377 previous_path,
16378 });
16379 }
16380}
16381
16382fn is_git_status_code(byte: u8) -> bool {
16383 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16384}
16385
16386fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16387 for line in String::from_utf8_lossy(output).lines() {
16388 let Some((id, summary)) = line.split_once('\u{1f}') else {
16389 continue;
16390 };
16391 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16392 snapshot.truncated = true;
16393 break;
16394 }
16395 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16396 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16397 snapshot.recent_commits.push(GitCommitSummary { id, summary });
16398 }
16399}
16400
16401fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16402 if value.len() <= max_bytes {
16403 return value.to_owned();
16404 }
16405 *truncated = true;
16406 let mut boundary = max_bytes;
16407 while boundary > 0 && !value.is_char_boundary(boundary) {
16408 boundary -= 1;
16409 }
16410 value[..boundary].to_owned()
16411}
16412
16413fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16414 if message.is_empty() {
16415 return;
16416 }
16417 let normalized = message
16418 .chars()
16419 .map(|character| if character.is_control() { ' ' } else { character })
16420 .collect::<String>();
16421 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16422 if !diagnostic.is_empty() {
16423 diagnostic.push_str("; ");
16424 }
16425 diagnostic.push_str(normalized.trim());
16426 snapshot.diagnostic = Some(truncate_git_field(
16427 &diagnostic,
16428 GIT_DIAGNOSTIC_MAX_BYTES,
16429 &mut snapshot.truncated,
16430 ));
16431}
16432
16433async fn run_git_bounded(
16434 root: &str,
16435 arguments: &[&str],
16436 output_limit: usize,
16437) -> io::Result<GitCommandOutput> {
16438 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16439}
16440
16441async fn read_git_history_bounded(
16442 root: &str,
16443 path: Option<&RepositoryPath>,
16444 before: Option<&GitObjectId>,
16445 limit: u16,
16446) -> Result<GitHistoryPage, NodeFailure> {
16447 let path = path
16448 .map(|path| {
16449 path.as_utf8().ok_or_else(|| {
16450 failure(
16451 NodeFailureCode::InvalidRepositoryPath,
16452 "git history path must use UTF-8",
16453 )
16454 })
16455 })
16456 .transpose()?
16457 .unwrap_or(".");
16458 let count = (usize::from(limit) + 1).to_string();
16459 let start = before
16460 .map(|revision| format!("{}^", revision.as_str()))
16461 .unwrap_or_else(|| "HEAD".to_owned());
16462 let arguments = [
16463 "--no-pager",
16464 "log",
16465 "--no-decorate",
16466 "--date=iso-strict",
16467 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16468 "-n",
16469 count.as_str(),
16470 start.as_str(),
16471 "--",
16472 path,
16473 ];
16474 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16475 .await
16476 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16477 if output.timed_out {
16478 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16479 }
16480 if !output.success {
16481 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16482 }
16483 let mut commits = parse_git_history(&output.stdout);
16484 let has_more = commits.len() > usize::from(limit) || output.truncated;
16485 commits.truncate(usize::from(limit));
16486 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16487 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16488}
16489
16490fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16491 String::from_utf8_lossy(output)
16492 .split('\u{1e}')
16493 .filter_map(|record| {
16494 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16495 if fields.len() != 9 {
16496 return None;
16497 }
16498 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16499 let parents = if fields[1].is_empty() {
16500 Vec::new()
16501 } else {
16502 fields[1]
16503 .split(' ')
16504 .map(|value| GitObjectId::new(value.to_owned()))
16505 .collect::<Result<Vec<_>, _>>()
16506 .ok()?
16507 };
16508 Some(GitCommitDetails {
16509 id,
16510 parents,
16511 subject: bounded_git_text(fields[2], 512),
16512 author_name: bounded_git_text(fields[3], 256),
16513 author_email: bounded_git_text(fields[4], 320),
16514 authored_at: bounded_git_text(fields[5], 64),
16515 committer_name: bounded_git_text(fields[6], 256),
16516 committer_email: bounded_git_text(fields[7], 320),
16517 committed_at: bounded_git_text(fields[8], 64),
16518 signature_status: GitSignatureStatus::NoSignature,
16519 signer: None,
16520 })
16521 })
16522 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16523 .collect()
16524}
16525
16526fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16527 let normalized = value
16528 .chars()
16529 .map(|character| if character.is_control() { ' ' } else { character })
16530 .collect::<String>();
16531 if normalized.len() <= max_bytes {
16532 return normalized;
16533 }
16534 let mut boundary = max_bytes;
16535 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16536 boundary -= 1;
16537 }
16538 normalized[..boundary].to_owned()
16539}
16540
16541async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16542 let path = request.path.as_ref().map(|path| {
16543 path.as_utf8()
16544 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16545 }).transpose()?;
16546 let mut arguments = match &request.mode {
16547 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16548 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16549 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16550 };
16551 if let GitDiffMode::Commit { revision } = &request.mode {
16552 arguments.push(revision.as_str());
16553 }
16554 arguments.push("--");
16555 if let Some(path) = path {
16556 arguments.push(path);
16557 }
16558 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16559 .await
16560 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16561 if output.timed_out {
16562 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16563 }
16564 if !output.success {
16565 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16566 }
16567 Ok(GitDiff {
16568 mode: request.mode,
16569 path: request.path,
16570 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16571 truncated: output.truncated,
16572 })
16573}
16574
16575async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16576 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16577 ResponseEnvelope { request_id: envelope.request_id, result }
16578}
16579
16580async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16581 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16582 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16583 }
16584 let read_only = matches!(
16585 &request,
16586 NodeRequest::Snapshot
16587 | NodeRequest::Resync { .. }
16588 | NodeRequest::BrowseHostDirectories { .. }
16589 | NodeRequest::InspectWorkspace { .. }
16590 | NodeRequest::ReadWorkspaceFile { .. }
16591 | NodeRequest::ReadGitHistory { .. }
16592 | NodeRequest::ReadGitDiff { .. }
16593 | NodeRequest::CatalogNativeSessions { .. }
16594 | NodeRequest::PageNativeSessions { .. }
16595 | NodeRequest::PreviewNativeSession { .. }
16596 | NodeRequest::PreviewSessionRecord { .. }
16597 );
16598 let _mutation_guard = if read_only {
16599 None
16600 } else {
16601 let guard = shared.mutation_gate.lock().await;
16602 if shared.shutdown.load(Ordering::Acquire) {
16603 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16604 }
16605 Some(guard)
16606 };
16607 match request {
16608 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16609 event_sequence: shared.current_sequence(),
16610 controller: shared.controller_state(),
16611 snapshot: shared.snapshot(),
16612 }),
16613 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16614 NodeRequest::ArmHarnessMcpReservation {
16615 reservation_id,
16616 activation_digest,
16617 spawn_spec,
16618 launch,
16619 expires_at_unix_ms,
16620 } => {
16621 shared.require_controller(connection_id, role)?;
16622 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16623 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16624 })?;
16625 let expires_at_unix_ms = registry
16626 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16627 .await
16628 .map_err(harness_mcp_failure)?;
16629 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16630 }
16631 NodeRequest::SpawnSpecWithHarnessMcp {
16632 reservation_id,
16633 activation_digest,
16634 spec,
16635 deadline_unix_ms,
16636 } => {
16637 shared.require_controller(connection_id, role)?;
16638 let receipt = shared
16639 .spawn_from_spec_with_harness_mcp(
16640 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16641 )
16642 .await?;
16643 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16644 }
16645 NodeRequest::ActivateHarnessMcpReservation {
16646 reservation_id,
16647 activation_digest,
16648 record_id,
16649 session,
16650 } => {
16651 shared.require_controller(connection_id, role)?;
16652 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16653 current.instance_id == session.session.instance_id
16654 && current.generation == session.session.generation
16655 }).and_then(|current| current.process_id).ok_or_else(|| {
16656 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16657 })?;
16658 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16659 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16660 })?.activate(
16661 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16662 ).map_err(harness_mcp_failure)?;
16663 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16664 }
16665 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16666 shared.require_controller(connection_id, role)?;
16667 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16668 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16669 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16670 if let Some(instance_id) = instance_id {
16671 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16672 }
16673 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16674 }
16675 NodeRequest::PutHarnessMcpReplyChunk {
16676 reservation_id, activation_digest, record_id, session, call_id,
16677 offset, final_chunk, chunk_hex,
16678 } => {
16679 shared.require_controller(connection_id, role)?;
16680 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16681 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16682 })?.put_reply_chunk(
16683 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16684 offset, final_chunk, &chunk_hex,
16685 ).map_err(harness_mcp_failure)?;
16686 Ok(NodeResponse::ReplyChunkAccepted {
16687 reservation_id, activation_digest, record_id, session, call_id,
16688 next_offset, completed,
16689 })
16690 }
16691 NodeRequest::RejectHarnessMcpCall {
16692 reservation_id, activation_digest, record_id, session, call_id, reason,
16693 } => {
16694 shared.require_controller(connection_id, role)?;
16695 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16696 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16697 })?.reject_call(
16698 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16699 ).map_err(harness_mcp_failure)?;
16700 Ok(NodeResponse::CallRejected {
16701 reservation_id, activation_digest, record_id, session, call_id,
16702 })
16703 }
16704 NodeRequest::BeginDeliveryStage { manifest } => {
16705 shared.require_controller(connection_id, role)?;
16706 shared.begin_delivery_stage(manifest)
16707 }
16708 NodeRequest::PutDeliveryBlobChunk {
16709 stage_id,
16710 blob_digest,
16711 offset,
16712 chunk_hex,
16713 } => {
16714 shared.require_controller(connection_id, role)?;
16715 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16716 }
16717 NodeRequest::CommitDeliveryStage { stage_id } => {
16718 shared.require_controller(connection_id, role)?;
16719 let receipt = shared.commit_delivery_stage(stage_id)?;
16720 Ok(NodeResponse::DeliveryCommitted { receipt })
16721 }
16722 NodeRequest::AbortDeliveryStage { stage_id } => {
16723 shared.require_controller(connection_id, role)?;
16724 shared.abort_delivery_stage(&stage_id)?;
16725 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16726 }
16727 NodeRequest::BrowseHostDirectories { directory, after } => {
16728 let listing = shared.browse_host_directories(directory, after).await?;
16729 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16730 }
16731 NodeRequest::InspectWorkspace { workspace_id } => {
16732 let inspection = shared.inspect_workspace(workspace_id).await?;
16733 Ok(NodeResponse::WorkspaceInspected { inspection })
16734 }
16735 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16736 let file = shared.read_workspace_file(workspace_id, path).await?;
16737 Ok(NodeResponse::WorkspaceFileRead { file })
16738 }
16739 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16740 shared.require_controller(connection_id, role)?;
16741 let file = shared
16742 .write_workspace_file(workspace_id, path, expected_revision, text)
16743 .await?;
16744 Ok(NodeResponse::WorkspaceFileWritten { file })
16745 }
16746 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16747 shared.require_controller(connection_id, role)?;
16748 let file = shared.create_workspace_file(workspace_id, path).await?;
16749 Ok(NodeResponse::WorkspaceFileCreated { file })
16750 }
16751 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16752 shared.require_controller(connection_id, role)?;
16753 let entry = shared
16754 .create_workspace_directory(workspace_id.clone(), path)
16755 .await?;
16756 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16757 }
16758 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16759 if role != ClientRole::Operator {
16760 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16761 }
16762 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16763 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16764 }
16765 let page = shared
16766 .read_git_history(workspace_id.clone(), path, before, limit)
16767 .await?;
16768 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16769 }
16770 NodeRequest::ReadGitDiff { workspace_id, request } => {
16771 if role != ClientRole::Operator {
16772 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16773 }
16774 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16775 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16776 }
16777 NodeRequest::CatalogNativeSessions { route, limit } => {
16778 if role != ClientRole::Operator {
16779 return Err(failure(
16780 NodeFailureCode::ObserverReadOnly,
16781 "native session catalog requires an operator connection",
16782 ));
16783 }
16784 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16785 return Err(failure(
16786 NodeFailureCode::InvalidRequest,
16787 "native session catalog limit is invalid",
16788 ));
16789 }
16790 let (entries, summary) = shared
16791 .catalog_native_sessions(route.clone(), limit)
16792 .await?;
16793 Ok(NodeResponse::NativeSessionsCataloged {
16794 route,
16795 entries,
16796 summary: Some(summary),
16797 })
16798 }
16799 NodeRequest::PageNativeSessions {
16800 route,
16801 window,
16802 catalog_revision,
16803 recent_cutoff_unix_ms,
16804 after_selection_id,
16805 limit,
16806 } => {
16807 if role != ClientRole::Operator {
16808 return Err(failure(
16809 NodeFailureCode::ObserverReadOnly,
16810 "native session catalog paging requires an operator connection",
16811 ));
16812 }
16813 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16814 || after_selection_id
16815 .as_deref()
16816 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16817 {
16818 return Err(failure(
16819 NodeFailureCode::InvalidRequest,
16820 "native session catalog page request is invalid",
16821 ));
16822 }
16823 let page = shared
16824 .page_native_sessions(
16825 route.clone(),
16826 window,
16827 catalog_revision,
16828 recent_cutoff_unix_ms,
16829 after_selection_id,
16830 limit,
16831 )
16832 .await?;
16833 Ok(NodeResponse::NativeSessionsPaged {
16834 route,
16835 page,
16836 })
16837 }
16838 NodeRequest::PreviewNativeSession {
16839 selection,
16840 message_limit,
16841 } => {
16842 if role != ClientRole::Operator {
16843 return Err(failure(
16844 NodeFailureCode::ObserverReadOnly,
16845 "native session preview requires an operator connection",
16846 ));
16847 }
16848 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16849 .contains(&message_limit)
16850 || selection.validate().is_err()
16851 {
16852 return Err(failure(
16853 NodeFailureCode::InvalidRequest,
16854 "native session preview request is invalid",
16855 ));
16856 }
16857 let preview = shared
16858 .preview_native_session(selection.clone(), message_limit)
16859 .await?;
16860 Ok(NodeResponse::NativeSessionPreviewed {
16861 selection,
16862 preview,
16863 })
16864 }
16865 NodeRequest::IndexNativeSession {
16866 selection,
16867 display_name,
16868 } => {
16869 shared.require_controller(connection_id, role)?;
16870 let record = shared
16871 .index_native_session(selection.clone(), display_name)
16872 .await?;
16873 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16874 }
16875 NodeRequest::PreviewSessionRecord {
16876 record_id,
16877 message_limit,
16878 } => {
16879 if role != ClientRole::Operator {
16880 return Err(failure(
16881 NodeFailureCode::ObserverReadOnly,
16882 "native session preview requires an operator connection",
16883 ));
16884 }
16885 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16886 .contains(&message_limit)
16887 {
16888 return Err(failure(
16889 NodeFailureCode::InvalidRequest,
16890 "native session preview request is invalid",
16891 ));
16892 }
16893 let record = shared.record(&record_id)?;
16894 let working_directory = shared.workspace_root(&record.workspace_id)?;
16895 if !platform::roots_equal(
16896 &working_directory,
16897 windows_path_text(&record.canonical_root),
16898 ) {
16899 return Err(failure(
16900 NodeFailureCode::SessionRecordConflict,
16901 "managed session workspace root changed; refusing to preview another directory",
16902 ));
16903 }
16904 let identity = record.provider_session.clone().ok_or_else(|| {
16905 failure(
16906 NodeFailureCode::BackendOperationFailed,
16907 "native session preview is unavailable for this session record",
16908 )
16909 })?;
16910 if identity.key != ProviderSessionKey::SessionId {
16911 return Err(failure(
16912 NodeFailureCode::BackendOperationFailed,
16913 "native session preview is unavailable for this session identity",
16914 ));
16915 }
16916 let preview = shared
16917 .preview_native_session_by_session_id(
16918 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16919 )
16920 .await?;
16921 if preview.session_id != identity.id {
16922 return Err(failure(
16923 NodeFailureCode::SessionRecordConflict,
16924 "native session preview identity changed while loading",
16925 ));
16926 }
16927 shared.revalidate_session_record_preview(&record, &identity)?;
16928 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16929 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16930 record_id: record_id.clone(),
16931 summary: SessionHistorySummaryV1::from(&preview),
16932 });
16933 Ok(NodeResponse::SessionRecordPreviewed {
16934 record_id,
16935 preview,
16936 })
16937 }
16938 NodeRequest::AcquireController { lease_ms } => {
16939 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16940 Ok(NodeResponse::Controller { controller: Some(controller) })
16941 }
16942 NodeRequest::ReleaseController => {
16943 shared.release_controller(connection_id);
16944 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16945 }
16946 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16947 shared.require_controller(connection_id, role)?;
16948 let root = require_windows_path(root)?;
16949 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16950 let workspace = shared.register_workspace(workspace_id, root).await?;
16951 Ok(NodeResponse::WorkspaceRegistered { workspace })
16952 }
16953 NodeRequest::CreateStandaloneWorkspace {
16954 workspace_id,
16955 root,
16956 initial_branch,
16957 } => {
16958 shared.require_controller(connection_id, role)?;
16959 let root = require_windows_path(root)?;
16960 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16961 let workspace = shared
16962 .create_standalone_workspace(workspace_id, root, initial_branch)
16963 .await?;
16964 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16965 }
16966 NodeRequest::UnregisterWorkspace { workspace_id } => {
16967 shared.require_controller(connection_id, role)?;
16968 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16969 shared.unregister_workspace(&workspace_id)?;
16970 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16971 }
16972 NodeRequest::CreateWorktree {
16973 source_workspace_id,
16974 workspace_id,
16975 target_root,
16976 branch,
16977 base,
16978 } => {
16979 shared.require_controller(connection_id, role)?;
16980 let target_root = require_windows_path(target_root)?;
16981 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16982 let (worktree, workspace) = shared
16983 .create_worktree(
16984 source_workspace_id,
16985 workspace_id,
16986 target_root,
16987 branch,
16988 base,
16989 )
16990 .await?;
16991 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16992 }
16993 NodeRequest::RemoveWorktree {
16994 source_workspace_id,
16995 target_root,
16996 } => {
16997 shared.require_controller(connection_id, role)?;
16998 let native_target_root = require_windows_path(target_root.clone())?;
16999 shared.reject_managed_reservation(None, Some(&native_target_root))?;
17000 let workspace_id = shared
17001 .remove_worktree(source_workspace_id, native_target_root)
17002 .await?;
17003 Ok(NodeResponse::WorktreeRemoved {
17004 target_root,
17005 workspace_id,
17006 })
17007 }
17008 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
17009 shared.require_controller(connection_id, role)?;
17010 if !terminal_size.is_valid() {
17011 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17012 }
17013 if let Some(prompt) = initial_prompt.as_deref() {
17014 validate_node_text("spawn initial prompt", prompt)?;
17015 }
17016 match shared
17017 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
17018 .await
17019 {
17020 Ok(session) => {
17021 shared.mark_spawn_provisional(session.clone());
17022 Ok(NodeResponse::SpawnAccepted { session })
17023 }
17024 Err(err) => {
17025 tracing::warn!(
17026 %provider,
17027 %workspace_id,
17028 ?mode,
17029 code = ?err.code,
17030 message = %err.message,
17031 "session spawn refused"
17032 );
17033 Err(err)
17034 }
17035 }
17036 }
17037 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
17038 shared.require_controller(connection_id, role)?;
17039 if !terminal_size.is_valid() {
17040 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
17041 }
17042 if let Some(prompt) = initial_prompt.as_deref() {
17043 validate_node_text("spawn initial prompt", prompt)?;
17044 }
17045 let plan = backend_paths::plan_launch_options(
17046 provider.as_str(),
17047 mode,
17048 options.model.is_some(),
17049 options.effort.is_some(),
17050 )
17051 .map_err(|r| {
17052 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
17053 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
17054 })?;
17055 let selection = spawn_selection(&options)?;
17056 let nick_model = selection.as_ref().map(|s| s.model.clone());
17057 let pty_selection = match plan {
17058 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
17059 _ => None,
17060 };
17061 let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
17062 SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
17063 SPAWN_NICK_MODEL.scope(nick_model, SPAWN_SESSION_OPTIONS
17064 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)))
17065 .await
17066 }).await
17067 }).await;
17068 match spawned {
17069 Ok(session) => {
17070 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
17071 shared
17072 .pending_acp_options
17073 .lock()
17074 .unwrap_or_else(|p| p.into_inner())
17075 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17076 }
17077 shared.mark_spawn_provisional(session.clone());
17078 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17079 Ok(NodeResponse::SpawnAccepted { session })
17080 }
17081 Err(err) => {
17082 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17083 Err(err)
17084 }
17085 }
17086 }
17087 NodeRequest::SpawnSpec { spec } => {
17088 shared.require_controller(connection_id, role)?;
17089 let receipt = shared.spawn_from_spec(spec).await?;
17090 Ok(NodeResponse::SpawnSpecAccepted { receipt })
17091 }
17092 NodeRequest::SpawnManagedWorktree { request } => {
17093 shared.require_controller(connection_id, role)?;
17094 let receipt = shared.spawn_managed_worktree(request).await?;
17095 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17096 }
17097 NodeRequest::SpawnManagedWorktreeV2 { request } => {
17098 shared.require_controller(connection_id, role)?;
17099 let receipt = shared.spawn_managed_worktree_v2(request).await?;
17100 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17101 }
17102 NodeRequest::CleanupManagedWorktree { lease_id } => {
17103 shared.require_controller(connection_id, role)?;
17104 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17105 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17106 }
17107 NodeRequest::DiscoverHistory { session, limit } => {
17108 controlled_session(shared, connection_id, role, &session)?;
17109 let candidates = shared.discover_history(&session, limit).await?;
17110 Ok(NodeResponse::HistoryDiscovered {
17111 session,
17112 candidates,
17113 })
17114 }
17115 NodeRequest::LoadHistory {
17116 session,
17117 candidate_id,
17118 } => {
17119 controlled_session(shared, connection_id, role, &session)?;
17120 let loaded = shared
17121 .load_history(&session, candidate_id)
17122 .await?;
17123 Ok(NodeResponse::HistoryLoaded {
17124 session,
17125 session_id: loaded.session_id,
17126 message_count: loaded.message_count,
17127 completed_turn_count: loaded.completed_turn_count,
17128 })
17129 }
17130 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17131 shared.require_controller(connection_id, role)?;
17132 let context = shared
17133 .export_context_pack_for_session_record(&record_id, &session, false)
17134 .await?;
17135 Ok(NodeResponse::ContextPackForSessionRecordExported {
17136 record_id,
17137 session,
17138 context,
17139 })
17140 }
17141 NodeRequest::ExportContextPack { session } => {
17142 controlled_session(shared, connection_id, role, &session)?;
17143 let context = shared.export_context_pack(&session).await?;
17144 Ok(NodeResponse::ContextPackExported { context })
17145 }
17146 NodeRequest::ForgetContextPack { context_id } => {
17147 shared.require_controller(connection_id, role)?;
17148 shared.forget_context_pack(&context_id)?;
17149 Ok(NodeResponse::ContextPackForgotten { context_id })
17150 }
17151 NodeRequest::ResolveDurableContextPack { context_id } => {
17152 let context = shared
17156 .context_catalog
17157 .read()
17158 .unwrap_or_else(|poisoned| poisoned.into_inner())
17159 .get(&context_id)
17160 .map(|pack| pack.receipt().clone())
17161 .ok_or_else(|| {
17162 failure(
17163 NodeFailureCode::UnknownContextPack,
17164 "context pack is not present",
17165 )
17166 })?;
17167 Ok(NodeResponse::DurableContextPackResolved { context })
17168 }
17169 NodeRequest::ReadContextPack { digest } => {
17170 let (id, byte_len, bytes) = shared
17175 .context_catalog
17176 .read()
17177 .unwrap_or_else(|poisoned| poisoned.into_inner())
17178 .get_by_digest(&digest)
17179 .map(|pack| {
17180 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17181 })
17182 .ok_or_else(|| {
17183 failure(
17184 NodeFailureCode::UnknownContextPack,
17185 &format!("context pack {digest} not on this node"),
17186 )
17187 })?;
17188 tracing::debug!(
17189 digest = digest.as_str(),
17190 byte_len,
17191 "read context pack bytes",
17192 );
17193 Ok(NodeResponse::ContextPackBytesRead {
17194 pack: ContextPackBytesRead {
17195 digest,
17196 id,
17197 byte_len,
17198 bytes,
17199 },
17200 })
17201 }
17202 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17203 let provider = controlled_session(shared, connection_id, role, &session)?;
17204 if let Some(prompt) = initial_prompt.as_deref() {
17205 validate_node_text("resume initial prompt", prompt)?;
17206 }
17207 let runtime_requirement = if initial_prompt.is_some() {
17208 ProviderRuntimeRequirement::ResumeWithPrompt
17209 } else {
17210 ProviderRuntimeRequirement::Resume
17211 };
17212 shared.require_session_runtime_policy(&session, runtime_requirement)?;
17213 let runtime_policy = shared
17214 .admit_provider_runtime(&provider, runtime_requirement)
17215 .await?;
17216 let working_directory = shared.bound_workspace_root(&session)?;
17217 let request = ResumeLaunchRequest {
17218 working_directory,
17219 terminal_size,
17220 initial_prompt,
17221 };
17222 request
17223 .validate()
17224 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17225 let command = shared.prepare_command(ControlCommand::Resume {
17226 instance_id: session.session.instance_id,
17227 target: ResumeTarget::CurrentProvider,
17228 runtime_policy,
17229 request,
17230 });
17231 shared.arm_resume(&session, command.id, runtime_policy)?;
17232 let dispatch = shared.dispatch_envelope(command);
17233 if let Err(error) = dispatch {
17234 shared.clear_armed_resume(&session);
17235 return Err(error);
17236 }
17237 Ok(NodeResponse::Accepted)
17238 }
17239 NodeRequest::RenameSessionRecord {
17240 record_id,
17241 display_name,
17242 } => {
17243 shared.require_controller(connection_id, role)?;
17244 let record = shared.rename_session_record(&record_id, display_name)?;
17245 Ok(NodeResponse::SessionRecordUpdated { record })
17246 }
17247 NodeRequest::SetSessionTask {
17248 record_id,
17249 expected_revision,
17250 target,
17251 } => {
17252 shared.require_controller(connection_id, role)?;
17253 let record = shared.set_session_task(&record_id, expected_revision, target)?;
17254 Ok(NodeResponse::SessionRecordUpdated { record })
17255 }
17256 NodeRequest::IndexProviderSession {
17257 workspace_id,
17258 provider,
17259 identity,
17260 display_name,
17261 } => {
17262 shared.require_controller(connection_id, role)?;
17263 let record = shared.index_provider_session(
17264 workspace_id,
17265 provider,
17266 identity,
17267 display_name,
17268 )?;
17269 Ok(NodeResponse::ProviderSessionIndexed { record })
17270 }
17271 NodeRequest::ResumeSessionRecord {
17272 record_id,
17273 terminal_size,
17274 initial_prompt,
17275 } => {
17276 shared.require_controller(connection_id, role)?;
17277 if let Some(prompt) = initial_prompt.as_deref() {
17278 validate_node_text("managed resume initial prompt", prompt)?;
17279 }
17280 let (record, session) = shared
17281 .resume_session_record(
17282 &record_id,
17283 terminal_size,
17284 initial_prompt,
17285 )
17286 .await?;
17287 Ok(NodeResponse::SessionRecordResumed { record, session })
17288 }
17289 NodeRequest::ForgetSessionRecord { record_id } => {
17290 shared.require_controller(connection_id, role)?;
17291 shared.forget_session_record(&record_id).await?;
17292 Ok(NodeResponse::SessionRecordForgotten { record_id })
17293 }
17294 NodeRequest::Prompt { session, text } => {
17295 let agent_id = controlled_session(shared, connection_id, role, &session)?;
17296 validate_node_text("prompt", &text)?;
17297 shared.require_session_runtime_policy(
17298 &session,
17299 ProviderRuntimeRequirement::SemanticPrompt,
17300 )?;
17301 let framing = prompt_framing(&agent_id);
17302 shared
17303 .dispatch_input_bounded(
17304 &session,
17305 InputAction::SubmitPrompt(PromptPayload { text, framing }),
17306 )
17307 .await?;
17308 Ok(NodeResponse::Accepted)
17309 }
17310 NodeRequest::Paste { session, text } => {
17311 controlled_session(shared, connection_id, role, &session)?;
17312 validate_node_text("paste", &text)?;
17313 shared.require_session_runtime_policy(
17314 &session,
17315 ProviderRuntimeRequirement::SemanticPrompt,
17316 )?;
17317 shared
17318 .dispatch_input_bounded(
17319 &session,
17320 InputAction::InsertDraft(PromptPayload {
17321 text,
17322 framing: PromptFraming::BracketedPaste,
17323 }),
17324 )
17325 .await?;
17326 Ok(NodeResponse::Accepted)
17327 }
17328 NodeRequest::Input { session, text } => {
17329 controlled_session(shared, connection_id, role, &session)?;
17330 validate_node_text("terminal input", &text)?;
17331 shared
17332 .dispatch_input_bounded(
17333 &session,
17334 InputAction::TerminalText(TerminalText { text }),
17335 )
17336 .await?;
17337 Ok(NodeResponse::Accepted)
17338 }
17339 NodeRequest::TerminalBytes { session, bytes } => {
17340 controlled_session(shared, connection_id, role, &session)?;
17341 shared.sessionize_spawn(session.session.instance_id);
17342 let action = terminal_bytes_action(bytes)?;
17343 shared
17344 .dispatch_input_bounded(&session, action)
17345 .await?;
17346 Ok(NodeResponse::Accepted)
17347 }
17348 NodeRequest::TerminalControl { session, control } => {
17349 controlled_session(shared, connection_id, role, &session)?;
17350 shared
17351 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17352 .await?;
17353 Ok(NodeResponse::Accepted)
17354 }
17355 NodeRequest::Resize { session, size } => {
17356 controlled_session(shared, connection_id, role, &session)?;
17357 shared.sessionize_spawn(session.session.instance_id);
17358 if !size.is_valid() {
17359 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17360 }
17361 shared.dispatch_resize_bounded(&session, size).await?;
17362 Ok(NodeResponse::Accepted)
17363 }
17364 NodeRequest::Interrupt { session } => {
17365 controlled_session(shared, connection_id, role, &session)?;
17366 shared
17367 .dispatch_input_bounded(
17368 &session,
17369 InputAction::TerminalControl(TerminalControl::Interrupt),
17370 )
17371 .await?;
17372 Ok(NodeResponse::Accepted)
17373 }
17374 NodeRequest::Stop { session, force } => {
17375 controlled_session(shared, connection_id, role, &session)?;
17376 shared.sessionize_spawn(session.session.instance_id);
17377 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17378 shared.retire_session_grok_leader(session.session.instance_id);
17379 Ok(NodeResponse::Accepted)
17380 }
17381 NodeRequest::Remove { session } => {
17382 controlled_session(shared, connection_id, role, &session)?;
17383 shared.remove_session(&session).await?;
17384 Ok(NodeResponse::Accepted)
17385 }
17386 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17387 shared.require_controller(connection_id, role)?;
17388 let snapshot = shared.require_acp_session(&session)?;
17389 let interaction = snapshot
17390 .provider
17391 .interactions
17392 .iter()
17393 .find(|interaction| {
17394 interaction_correlation(
17395 session.session.instance_id,
17396 session.session.generation,
17397 interaction.id.0,
17398 ) == correlation_id
17399 })
17400 .ok_or_else(|| failure(
17401 NodeFailureCode::InvalidRequest,
17402 "unknown interaction correlation",
17403 ))?;
17404 if interaction.status != ProviderInteractionStatus::Pending {
17405 return Err(failure(
17406 NodeFailureCode::BackendOperationFailed,
17407 "interaction has already been resolved",
17408 ));
17409 }
17410 response
17411 .validate_for(interaction.interaction_kind)
17412 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17413 let interaction_id = interaction.id;
17414 let response_kind = response.kind();
17415 let response_outcome = response.outcome();
17416 shared.dispatch(ControlCommand::ResolveInteraction {
17417 instance_id: session.session.instance_id,
17418 generation: session.session.generation,
17419 interaction_id,
17420 response,
17421 })?;
17422 tracing::info!(
17428 node_id = %shared.node_id,
17429 session = ?session.session,
17430 correlation_id = %correlation_id,
17431 interaction_id = interaction_id.0,
17432 response_kind = ?response_kind,
17433 outcome = ?response_outcome,
17434 "interaction resolved",
17435 );
17436 Ok(NodeResponse::Accepted)
17437 }
17438 NodeRequest::SetSessionMode { session, mode_id } => {
17439 shared.require_controller(connection_id, role)?;
17440 shared.require_acp_session(&session)?;
17441 shared.dispatch(ControlCommand::SetSessionMode {
17442 instance_id: session.session.instance_id,
17443 mode_id,
17444 })?;
17445 Ok(NodeResponse::Accepted)
17446 }
17447 NodeRequest::SetSessionConfigOption {
17448 session,
17449 option_id,
17450 value_json,
17451 } => {
17452 shared.require_controller(connection_id, role)?;
17453 shared.require_acp_session(&session)?;
17454 shared.dispatch(ControlCommand::SetSessionConfigOption {
17455 instance_id: session.session.instance_id,
17456 option_id,
17457 value_json,
17458 })?;
17459 Ok(NodeResponse::Accepted)
17460 }
17461 NodeRequest::SetSessionModel { session, model_id } => {
17462 shared.require_controller(connection_id, role)?;
17463 shared.require_acp_session(&session)?;
17464 shared.dispatch(ControlCommand::SetSessionModel {
17465 instance_id: session.session.instance_id,
17466 model_id,
17467 })?;
17468 Ok(NodeResponse::Accepted)
17469 }
17470 NodeRequest::Shutdown => {
17471 shared.require_controller(connection_id, role)?;
17472 shared.begin_shutdown_locked().await?;
17473 Ok(NodeResponse::ShuttingDown)
17474 }
17475 }
17476}
17477
17478fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17479 shared.require_controller(connection_id, role)?;
17480 shared.validate_address(session)
17481}
17482
17483fn select_history_candidate(
17489 candidates: &[HistoryCandidateSummary],
17490 provider_session_id: Option<&str>,
17491) -> Result<String, NodeFailure> {
17492 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17493 Some(id) => candidate.session_id_hint == id,
17494 None => true,
17495 });
17496 let candidate = matches.next().ok_or_else(|| {
17497 failure(
17498 NodeFailureCode::SessionRecordConflict,
17499 if provider_session_id.is_some() {
17500 "no history candidate matches the managed provider session"
17501 } else {
17502 "no history candidate exists for the managed session"
17503 },
17504 )
17505 })?;
17506 if matches.next().is_some() {
17507 return Err(failure(
17508 NodeFailureCode::SessionRecordConflict,
17509 if provider_session_id.is_some() {
17510 "history identity is ambiguous for the managed provider session"
17511 } else {
17512 "history is ambiguous without a verified provider session identity"
17513 },
17514 ));
17515 }
17516 Ok(candidate.id.clone())
17517}
17518
17519fn resolved_spawn_profile_summary(
17520 profile: &SpawnProfileDefaults,
17521 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17522) -> Option<SpawnProfileSummary> {
17523 let environment_profile = match profile.environment_profile_id.as_ref() {
17524 None => None,
17525 Some(profile_id) => {
17526 let binding = environment_profiles.get(profile_id)?;
17527 if binding.provider != profile.provider
17528 || binding.native_profile_id(profile.mode).is_none()
17529 {
17530 return None;
17531 }
17532 Some(ResolvedEnvironmentProfileReceipt {
17533 profile_id: binding.id.clone(),
17534 profile_revision: binding.revision.clone(),
17535 network_allowlist: None,
17536 browser_profile_id: None,
17537 })
17538 }
17539 };
17540 Some(SpawnProfileSummary {
17541 id: profile.profile_id.clone(),
17542 revision: profile.revision.clone(),
17543 environment_profile,
17544 })
17545}
17546
17547fn session_record_export_target_matches(
17566 expected: &ManagedSessionRecord,
17567 current: &ManagedSessionRecord,
17568 session: &SessionAddress,
17569 allow_clean_detachment: bool,
17570) -> bool {
17571 if current == expected && current.active_session.as_ref() == Some(session) {
17572 return current.state == ManagedSessionState::Live;
17573 }
17574 allow_clean_detachment
17575 && current.state == ManagedSessionState::Dormant
17576 && current.active_session.is_none()
17577 && current.record_id == expected.record_id
17578 && current.display_name == expected.display_name
17579 && current.provider == expected.provider
17580 && current.mode == expected.mode
17581 && current.workspace_id == expected.workspace_id
17582 && current.canonical_root == expected.canonical_root
17583 && current.provider_session == expected.provider_session
17584 && current.environment_profile == expected.environment_profile
17585 && current.bundle == expected.bundle
17586 && current.context_id == expected.context_id
17587 && current.context == expected.context
17588 && current.exported_context == expected.exported_context
17589 && current.task_binding == expected.task_binding
17590 && current.created_at_unix_ms == expected.created_at_unix_ms
17591 && current.last_error == expected.last_error
17592}
17593
17594fn session_record_context_export_binding_is_exact(
17595 expected: &ManagedSessionRecord,
17596 current: &ManagedSessionRecord,
17597 identity: Option<&ProviderSessionIdentity>,
17598 session: &SessionAddress,
17599 binding: &SessionBinding,
17600 provider: &AgentId,
17601 current_root: &str,
17602 allow_clean_detachment: bool,
17603) -> bool {
17604 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17605 && current.provider_session.as_ref() == identity
17606 && current.workspace_id == session.workspace_id
17607 && provider == ¤t.provider
17608 && binding.workspace_id == session.workspace_id
17609 && binding.generation == session.session.generation
17610 && binding.record_id.as_ref() == Some(¤t.record_id)
17611 && platform::roots_equal(
17612 current_root,
17613 windows_path_text(¤t.canonical_root),
17614 )
17615}
17616
17617fn session_record_context_export_source_is_usable(
17618 record: &ManagedSessionRecord,
17619 status: &SessionStatus,
17620 allow_clean_detachment: bool,
17621) -> bool {
17622 (record.state == ManagedSessionState::Live
17623 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17624 && context_pack_source_status_is_usable(status)
17625}
17626
17627fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17628 PromptFraming::BracketedPaste
17629}
17630
17631fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17632 if text.len() > MAX_NODE_TEXT_BYTES {
17633 return Err(failure(
17634 NodeFailureCode::InvalidRequest,
17635 &format!(
17636 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17637 text.len(),
17638 ),
17639 ));
17640 }
17641 Ok(())
17642}
17643
17644fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17645 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17646 return Err(failure(
17647 NodeFailureCode::InvalidRequest,
17648 &format!(
17649 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17650 bytes.len(),
17651 ),
17652 ));
17653 }
17654 Ok(())
17655}
17656
17657fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17658 validate_terminal_bytes(&bytes)?;
17659 Ok(InputAction::TerminalBytes(bytes))
17660}
17661
17662fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17663 if root.is_empty()
17664 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17665 || root.chars().any(char::is_control)
17666 {
17667 return Err(failure(
17668 NodeFailureCode::InvalidWorkspaceRoot,
17669 &format!(
17670 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17671 ),
17672 ));
17673 }
17674 Ok(())
17675}
17676
17677fn canonical_standalone_workspace_candidate(
17678 workspace_id: &WorkspaceId,
17679 root: &str,
17680) -> Result<String, NodeServerError> {
17681 let path = Path::new(root);
17682 match std::fs::symlink_metadata(path) {
17683 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17684 .map(|workspace| workspace.canonical_root),
17685 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17686 if !path.is_absolute() {
17687 return Err(NodeServerError::InvalidWorkspaceRoot {
17688 workspace_id: workspace_id.clone(),
17689 path: root.to_owned(),
17690 message: "path is not absolute".to_owned(),
17691 });
17692 }
17693 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17694 workspace_id: workspace_id.clone(),
17695 path: root.to_owned(),
17696 message: "path has no standalone directory name".to_owned(),
17697 })?;
17698 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17699 workspace_id: workspace_id.clone(),
17700 path: root.to_owned(),
17701 message: "path has no parent directory".to_owned(),
17702 })?;
17703 let canonical = std::fs::canonicalize(parent)
17704 .map(|parent| parent.join(name))
17705 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17706 workspace_id: workspace_id.clone(),
17707 path: root.to_owned(),
17708 message: error.to_string(),
17709 })?;
17710 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17711 NodeServerError::InvalidWorkspaceRoot {
17712 workspace_id: workspace_id.clone(),
17713 path: path.to_string_lossy().into_owned(),
17714 message: "canonical path is not valid Unicode".to_owned(),
17715 }
17716 })?;
17717 let canonical_root = platform::normalize_canonical_root(canonical_root);
17718 validate_workspace_root(workspace_id, &canonical_root)?;
17719 Ok(canonical_root)
17720 }
17721 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17722 workspace_id: workspace_id.clone(),
17723 path: root.to_owned(),
17724 message: error.to_string(),
17725 }),
17726 }
17727}
17728
17729fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17730 let Some(revision) = revision else {
17731 return Ok(());
17732 };
17733 if revision.is_empty()
17734 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17735 || revision.chars().any(char::is_control)
17736 {
17737 return Err(failure(
17738 NodeFailureCode::InvalidRequest,
17739 &format!(
17740 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17741 ),
17742 ));
17743 }
17744 Ok(())
17745}
17746
17747fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17748 let code = match error.kind {
17749 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17750 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17751 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17752 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17753 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17754 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17755 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17756 };
17757 failure(code, &error.message)
17758}
17759
17760fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17761 let code = match error.kind {
17762 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17763 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17764 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17765 NodeFailureCode::BackendOperationFailed
17766 }
17767 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17768 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17769 }
17770 };
17771 failure(code, error.message)
17772}
17773
17774fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17775 let (code, message) = match error.kind {
17776 GitWorktreeErrorKind::Invalid => (
17777 NodeFailureCode::InvalidRequest,
17778 "managed Git input is invalid",
17779 ),
17780 GitWorktreeErrorKind::NotRepository => (
17781 NodeFailureCode::NotGitRepository,
17782 "managed source is not a Git repository",
17783 ),
17784 GitWorktreeErrorKind::Conflict => (
17785 NodeFailureCode::WorktreeConflict,
17786 "managed Git identity conflicts with existing repository state",
17787 ),
17788 GitWorktreeErrorKind::Protected => (
17789 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17790 "managed worktree ownership validation failed",
17791 ),
17792 GitWorktreeErrorKind::Dirty => (
17793 NodeFailureCode::WorktreeDirty,
17794 "managed worktree contains uncommitted changes",
17795 ),
17796 GitWorktreeErrorKind::Locked => (
17797 NodeFailureCode::WorktreeLocked,
17798 "managed worktree is locked",
17799 ),
17800 GitWorktreeErrorKind::Failed => (
17801 NodeFailureCode::BackendOperationFailed,
17802 "managed Git operation failed",
17803 ),
17804 };
17805 NodeFailure { code, message: message.to_owned() }
17806}
17807
17808fn managed_spawn_receipt(
17809 resolved: &ResolvedSpawnSpec,
17810 incarnation_id: NodeIncarnationId,
17811 session: SessionAddress,
17812 lease: ManagedWorktreeLeaseSnapshot,
17813 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17814 bundle: Option<ResolvedBundleReceipt>,
17815 context: Option<ResolvedContextPackReceipt>,
17816) -> ManagedWorktreeSpawnReceipt {
17817 let mut managed = resolved.clone();
17818 managed.target.worktree_id = Some(lease.workspace_id.clone());
17819 ManagedWorktreeSpawnReceipt {
17820 spawn: managed.receipt_with_materialization(
17821 incarnation_id,
17822 session,
17823 environment_profile,
17824 bundle,
17825 context,
17826 ),
17827 lease,
17828 }
17829}
17830
17831fn validate_selected_worktree(
17832 source_root: &str,
17833 selected_root: &str,
17834 worktrees: &[NativeGitWorktreeSnapshot],
17835) -> Result<(), NodeFailure> {
17836 if !worktrees
17837 .iter()
17838 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17839 {
17840 return Err(failure(
17841 NodeFailureCode::WorktreeProtected,
17842 "source workspace is not an authoritative non-bare Git worktree root",
17843 ));
17844 }
17845 if !worktrees.iter().any(|worktree| {
17846 worktree_paths_equal(&worktree.path, selected_root)
17847 && !worktree.is_main
17848 && !worktree.is_bare
17849 }) {
17850 return Err(failure(
17851 NodeFailureCode::WorktreeProtected,
17852 "selected workspace is not an eligible Git worktree in the source repository",
17853 ));
17854 }
17855 Ok(())
17856}
17857
17858fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17859 let code = match error.kind() {
17860 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17861 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17862 NodeFailureCode::RepositoryPathUnsafe
17863 }
17864 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17865 WorkspaceFileReadErrorKind::NotRegularFile => {
17866 NodeFailureCode::RepositoryFileNotRegular
17867 }
17868 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17869 NodeFailureCode::RepositoryFileReadFailed
17870 }
17871 WorkspaceFileReadErrorKind::RevisionConflict => {
17872 NodeFailureCode::RepositoryFileRevisionConflict
17873 }
17874 WorkspaceFileReadErrorKind::AlreadyExists
17875 | WorkspaceFileReadErrorKind::ParentNotFound
17876 | WorkspaceFileReadErrorKind::ParentNotDirectory
17877 | WorkspaceFileReadErrorKind::Canceled => {
17878 NodeFailureCode::RepositoryFileReadFailed
17879 }
17880 };
17881 failure(code, "repository file read failed")
17882}
17883
17884fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17885 let code = match error.kind() {
17886 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17887 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17888 NodeFailureCode::RepositoryPathUnsafe
17889 }
17890 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17891 WorkspaceFileReadErrorKind::NotRegularFile => {
17892 NodeFailureCode::RepositoryFileNotRegular
17893 }
17894 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17895 NodeFailureCode::RepositoryFileWriteFailed
17896 }
17897 WorkspaceFileReadErrorKind::RevisionConflict => {
17898 NodeFailureCode::RepositoryFileRevisionConflict
17899 }
17900 WorkspaceFileReadErrorKind::AlreadyExists
17901 | WorkspaceFileReadErrorKind::ParentNotFound
17902 | WorkspaceFileReadErrorKind::ParentNotDirectory
17903 | WorkspaceFileReadErrorKind::Canceled => {
17904 NodeFailureCode::RepositoryFileWriteFailed
17905 }
17906 };
17907 failure(code, "repository file write failed")
17908}
17909
17910async fn settle_workspace_entry_create<T>(
17911 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17912 commit_state: Arc<AtomicU8>,
17913 deadline: Duration,
17914 timeout_message: &'static str,
17915 task_failure_message: &'static str,
17916) -> Result<T, NodeFailure> {
17917 let joined = match timeout(deadline, &mut task).await {
17918 Ok(joined) => joined,
17919 Err(_) => {
17920 if commit_state
17921 .compare_exchange(
17922 WORKSPACE_ENTRY_CREATE_PENDING,
17923 WORKSPACE_ENTRY_CREATE_CANCELED,
17924 Ordering::AcqRel,
17925 Ordering::Acquire,
17926 )
17927 .is_ok()
17928 {
17929 return Err(failure(
17930 NodeFailureCode::RepositoryEntryCreateTimedOut,
17931 timeout_message,
17932 ));
17933 }
17934 debug_assert_eq!(
17935 commit_state.load(Ordering::Acquire),
17936 WORKSPACE_ENTRY_CREATE_COMMITTING,
17937 );
17938 task.await
17939 }
17940 };
17941 joined
17942 .map_err(|_| {
17943 failure(
17944 NodeFailureCode::RepositoryEntryCreateFailed,
17945 task_failure_message,
17946 )
17947 })?
17948 .map_err(workspace_entry_create_failure)
17949}
17950
17951fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17952 let code = match error.kind() {
17953 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17954 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17955 NodeFailureCode::RepositoryPathUnsafe
17956 }
17957 WorkspaceFileReadErrorKind::AlreadyExists => {
17958 NodeFailureCode::RepositoryEntryAlreadyExists
17959 }
17960 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17961 NodeFailureCode::RepositoryParentNotFound
17962 }
17963 WorkspaceFileReadErrorKind::ParentNotDirectory
17964 | WorkspaceFileReadErrorKind::NotRegularFile => {
17965 NodeFailureCode::RepositoryParentNotDirectory
17966 }
17967 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17968 NodeFailureCode::RepositoryEntryCreateFailed
17969 }
17970 WorkspaceFileReadErrorKind::RevisionConflict => {
17971 NodeFailureCode::RepositoryEntryCreateFailed
17972 }
17973 WorkspaceFileReadErrorKind::Canceled => {
17974 NodeFailureCode::RepositoryEntryCreateTimedOut
17975 }
17976 };
17977 failure(code, "repository entry create failed")
17978}
17979
17980fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17981 let value = digest(&SHA256, bytes)
17982 .as_ref()
17983 .iter()
17984 .map(|byte| format!("{byte:02x}"))
17985 .collect();
17986 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17987}
17988
17989fn managed_spawn_request_digest_v2(
17990 request: &ManagedWorktreeSpawnRequestV2,
17991) -> Result<String, NodeFailure> {
17992 let encoded = serde_json::to_vec(request).map_err(|_| {
17993 failure(
17994 NodeFailureCode::InvalidRequest,
17995 "managed worktree V2 request could not be canonicalized",
17996 )
17997 })?;
17998 let mut value = String::with_capacity(71);
17999 value.push_str("sha256:");
18000 for byte in digest(&SHA256, &encoded).as_ref() {
18001 use std::fmt::Write as _;
18002 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
18003 }
18004 Ok(value)
18005}
18006
18007fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
18008 let code = match error {
18009 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
18010 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
18011 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
18012 NativeSessionPreviewError::UnsupportedProvider
18013 | NativeSessionPreviewError::PreviewUnavailable
18014 | NativeSessionPreviewError::SessionNotFound
18015 | NativeSessionPreviewError::AmbiguousSession => {
18016 NodeFailureCode::BackendOperationFailed
18017 }
18018 };
18019 failure(code, "native session preview failed")
18020}
18021
18022fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
18023 let code = match error.kind() {
18024 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
18025 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
18026 };
18027 failure(code, "host directory browse failed")
18028}
18029
18030fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
18031 let code = match error {
18032 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
18033 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
18034 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
18035 NodeFailureCode::DeliveryStageConflict
18036 }
18037 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
18038 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
18039 NodeFailureCode::DeliveryChunkOutOfOrder
18040 }
18041 DeliveryStoreError::BlobDigestMismatch => {
18042 NodeFailureCode::DeliveryBlobDigestMismatch
18043 }
18044 DeliveryStoreError::BundleDigestMismatch => {
18045 NodeFailureCode::DeliveryBundleDigestMismatch
18046 }
18047 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
18048 DeliveryStoreError::Unavailable
18049 | DeliveryStoreError::Corrupt
18050 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
18051 };
18052 failure(code, "delivery operation failed")
18053}
18054
18055fn validate_workspace_root(
18056 workspace_id: &WorkspaceId,
18057 canonical_root: &str,
18058) -> Result<(), NodeServerError> {
18059 #[cfg(windows)]
18060 if canonical_root.starts_with(r"\\") {
18061 return Err(NodeServerError::InvalidWorkspaceRoot {
18062 workspace_id: workspace_id.clone(),
18063 path: canonical_root.to_owned(),
18064 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
18065 });
18066 }
18067 if !platform::workspace_root_supported(canonical_root)
18068 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
18069 {
18070 return Err(NodeServerError::InvalidWorkspaceRoot {
18071 workspace_id: workspace_id.clone(),
18072 path: canonical_root.to_owned(),
18073 message: "canonical path must be absolute, bounded, and free of control characters"
18074 .to_owned(),
18075 });
18076 }
18077 Ok(())
18078}
18079
18080fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18081 NodeFailure {
18082 code,
18083 message: node_failure_category(code).to_owned(),
18084 }
18085}
18086
18087fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18088 let code = match error {
18089 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18090 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18091 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18092 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18093 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18094 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18095 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18096 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18097 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18098 };
18099 failure(code, "harness MCP proxy operation failed")
18100}
18101
18102static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18106
18107fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18114 let trace = launch.trace.as_ref()?;
18115 let trace_dir = std::env::var(&trace.dir_env).ok()?;
18116 if trace_dir.is_empty() {
18117 return None;
18118 }
18119 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18120 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18121 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18122}
18123
18124fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18125 let remaining = deadline.saturating_duration_since(Instant::now());
18126 if remaining.is_zero() {
18127 Err(failure(
18128 NodeFailureCode::SpawnDeadlineExceeded,
18129 "spawn deadline elapsed",
18130 ))
18131 } else {
18132 Ok(remaining)
18133 }
18134}
18135
18136fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18137 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18138 match deadline {
18139 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18140 None => Ok(bounded),
18141 }
18142}
18143
18144fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18145 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18146 failure(
18147 NodeFailureCode::SpawnDeadlineExceeded,
18148 "spawn dispatch exceeded the spawn deadline",
18149 )
18150 } else {
18151 error
18152 }
18153}
18154
18155fn spawn_runtime_capabilities(
18156 capabilities: &SpawnRequiredCapabilities,
18157) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18158 capabilities
18159 .iter()
18160 .map(|capability| match capability.as_str() {
18161 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18162 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18163 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18164 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18165 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18166 }
18167 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18168 _ => Err(failure(
18169 NodeFailureCode::UnsupportedSpawnCapability,
18170 "spawn required capability is unknown",
18171 )),
18172 })
18173 .collect()
18174}
18175
18176fn persistence_failure(_error: io::Error) -> NodeFailure {
18177 NodeFailure {
18178 code: NodeFailureCode::BackendOperationFailed,
18179 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18180 }
18181}
18182
18183fn node_failure_category(code: NodeFailureCode) -> &'static str {
18184 match code {
18185 NodeFailureCode::InvalidRequest => "invalid-request",
18186 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18187 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18188 NodeFailureCode::ReservationNotFound => "reservation-not-found",
18189 NodeFailureCode::ReservationConflict => "reservation-conflict",
18190 NodeFailureCode::ReservationExpired => "reservation-expired",
18191 NodeFailureCode::BindingMismatch => "binding-mismatch",
18192 NodeFailureCode::NotActivated => "not-activated",
18193 NodeFailureCode::CallNotFound => "call-not-found",
18194 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18195 NodeFailureCode::ResponseTooLarge => "response-too-large",
18196 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18197 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18198 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18199 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18200 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18201 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18202 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18203 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18204 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18205 NodeFailureCode::Unauthorized => "unauthorized",
18206 NodeFailureCode::ObserverReadOnly => "observer-read-only",
18207 NodeFailureCode::ControllerBusy => "controller-busy",
18208 NodeFailureCode::ControllerRequired => "controller-required",
18209 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18210 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18211 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18212 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18213 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18214 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18215 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18216 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18217 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18218 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18219 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18220 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18221 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18222 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18223 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18224 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18225 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18226 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18227 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18228 NodeFailureCode::GitReadFailed => "git-read-failed",
18229 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18230 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18231 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18232 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18233 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18234 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18235 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18236 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18237 NodeFailureCode::TurnInFlight => "turn-in-flight",
18238 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18239 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18240 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18241 "unsupported-network-allowlist-mapping"
18242 }
18243 NodeFailureCode::BrowserStationProbeUnavailable => {
18244 "browser-station-probe-unavailable"
18245 }
18246 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18247 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18248 NodeFailureCode::EnvironmentProfileBindingMismatch => {
18249 "environment-profile-binding-mismatch"
18250 }
18251 NodeFailureCode::UnknownBundle => "unknown-bundle",
18252 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18253 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18254 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18255 NodeFailureCode::ContextPackBusy => "context-pack-busy",
18256 NodeFailureCode::ContextPackMaterializationFailed => {
18257 "context-pack-materialization-failed"
18258 }
18259 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18260 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18261 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18262 NodeFailureCode::WorkspaceBusy => "workspace-busy",
18263 NodeFailureCode::LastWorkspace => "last-workspace",
18264 NodeFailureCode::NotGitRepository => "not-git-repository",
18265 NodeFailureCode::WorktreeConflict => "worktree-conflict",
18266 NodeFailureCode::WorktreeProtected => "worktree-protected",
18267 NodeFailureCode::WorktreeDirty => "worktree-dirty",
18268 NodeFailureCode::WorktreeLocked => "worktree-locked",
18269 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18270 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18271 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18272 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18273 "managed-worktree-profile-revision-mismatch"
18274 }
18275 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18276 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18277 "standalone-workspace-recovery-required"
18278 }
18279 NodeFailureCode::UnknownSession => "unknown-session",
18280 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18281 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18282 NodeFailureCode::SessionRecordBusy => "session-record-busy",
18283 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18284 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18285 NodeFailureCode::WorkspaceRegistrationRequired => {
18286 "workspace-registration-required"
18287 }
18288 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18289 NodeFailureCode::StaleGeneration => "stale-generation",
18290 NodeFailureCode::BackendBusy => "backend-busy",
18291 NodeFailureCode::BackendDisconnected => "backend-disconnected",
18292 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18293 NodeFailureCode::ShuttingDown => "shutting-down",
18294 }
18295}
18296
18297fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18298 NodeServerError::DurableState(io::Error::new(error.kind(), category))
18299}
18300
18301fn durable_state_load_error(error: io::Error) -> NodeServerError {
18302 let category = match session_registry::state_load_refusal(&error) {
18303 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18304 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18305 }
18306 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18307 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18308 }
18309 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18310 DURABLE_STATE_CONFLICT_ERROR
18311 }
18312 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18313 };
18314 durable_state_server_error(error, category)
18315}
18316
18317fn unix_time_ms() -> u64 {
18318 std::time::SystemTime::now()
18319 .duration_since(std::time::UNIX_EPOCH)
18320 .unwrap_or_default()
18321 .as_millis()
18322 .min(u64::MAX as u128) as u64
18323}
18324
18325fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18326 if record.provider_session.is_some() {
18327 ManagedSessionState::Dormant
18328 } else {
18329 ManagedSessionState::Unavailable
18330 }
18331}
18332
18333fn active_record_state(
18334 identity_admitted: bool,
18335 provider_session_is_known: bool,
18336) -> ManagedSessionState {
18337 if identity_admitted && !provider_session_is_known {
18338 ManagedSessionState::IdentityPending
18339 } else {
18340 ManagedSessionState::Live
18341 }
18342}
18343
18344fn managed_worktree_record_holders(
18345 records: &[ManagedSessionRecord],
18346) -> Vec<(SessionRecordId, WorkspaceId)> {
18347 records
18348 .iter()
18349 .filter(|record| record.provider_session.is_some())
18350 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18351 .collect()
18352}
18353
18354fn managed_spawn_record_ownership(
18355 binding: &SessionBinding,
18356 effective_runtime_policy: ProviderRuntimePolicy,
18357) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18358 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18359 if effective_runtime_policy.provider_session_identity {
18360 (binding.record_id.clone(), None)
18361 } else {
18362 (None, binding.record_id.clone())
18363 }
18364}
18365
18366#[derive(Debug, Error)]
18367pub enum NodeServerError {
18368 #[error("node endpoint must be a bounded absolute local transport path")]
18369 InvalidEndpoint,
18370 #[error("node access token must contain 1..=4096 bytes")]
18371 InvalidAccessToken,
18372 #[error("node HTTP observer must listen on a loopback address: {0}")]
18373 InvalidApiListen(std::net::SocketAddr),
18374 #[error("node browser bridge must listen on a loopback address: {0}")]
18375 InvalidBridgeListen(std::net::SocketAddr),
18376 #[error("node browser bridge token must contain 1..=4096 bytes")]
18377 InvalidBridgeToken,
18378 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18379 BridgeUnderlayRequiresBridgeListen,
18380 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18381 BridgeUnderlayRequiresBridgeToken,
18382 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18383 InvalidBridgeUnderlayToken,
18384 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18385 InvalidBridgeUnderlayKey,
18386 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18387 InvalidCallHome(std::net::SocketAddr),
18388 #[error("node could not reach its call-home relay: {0}")]
18389 CallHomeTransport(#[source] std::io::Error),
18390 #[error("node durable state path must be an absolute file path: {0}")]
18391 InvalidStatePath(String),
18392 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18393 InvalidHarnessMcpHelper,
18394 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18395 InvalidNetworkAllowlistCatalogPath(String),
18396 #[error("network allowlist catalog could not be read: {0}")]
18397 NetworkAllowlistCatalogIo(#[source] std::io::Error),
18398 #[error("network allowlist catalog line {line}: {message}")]
18399 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18400 #[error("network allowlist catalog: {0}")]
18401 InvalidNetworkAllowlistCatalog(String),
18402 #[error("network allowlist catalog exceeds the {max}-entry limit")]
18403 NetworkAllowlistCatalogCapacity { max: usize },
18404 #[error("the local state directory is unavailable; supply an explicit state path")]
18405 LocalStateDirectoryUnavailable,
18406 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18407 LocalRuntimeDirectoryUnavailable,
18408 #[error("node durable state failed: {0}")]
18409 DurableState(io::Error),
18410 #[error("node requires at least one configured workspace")]
18411 NoWorkspaces,
18412 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18413 InvalidWorkspaceRoot {
18414 workspace_id: WorkspaceId,
18415 path: String,
18416 message: String,
18417 },
18418 #[error("duplicate workspace ID: {0}")]
18419 DuplicateWorkspaceId(WorkspaceId),
18420 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18421 DuplicateWorkspaceRoot {
18422 first: WorkspaceId,
18423 second: WorkspaceId,
18424 root: String,
18425 },
18426 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18427 ManagedWorktreeProfileRequired(WorkspaceId),
18428 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18429 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18430 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18431 DuplicateManagedWorktreeProfile {
18432 workspace_id: WorkspaceId,
18433 profile_id: WorktreeProfileId,
18434 },
18435 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18436 ManagedWorktreeProfileCapacity {
18437 workspace_id: WorkspaceId,
18438 max: usize,
18439 },
18440 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18441 InvalidManagedWorktreeProfile {
18442 workspace_id: WorkspaceId,
18443 profile_id: WorktreeProfileId,
18444 message: String,
18445 },
18446 #[error("duplicate node environment profile: {0}")]
18447 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18448 #[error("node environment profile provider is unavailable: {0}")]
18449 EnvironmentProfileProviderUnavailable(AgentId),
18450 #[error("node environment profile capacity is {max}")]
18451 EnvironmentProfileCapacity { max: usize },
18452 #[error("node environment profiles reuse native profile ID '{0}'")]
18453 DuplicateNativeEnvironmentProfile(String),
18454 #[error("node native environment profile is invalid: {0}")]
18455 NativeEnvironmentProfile(String),
18456 #[error("node session-environment materialization root must be absolute")]
18457 InvalidSessionEnvironmentRoot,
18458 #[error("node session-environment materializer is unavailable")]
18459 SessionEnvironmentMaterializerRequired,
18460 #[error("node session-environment materializer failed to initialize")]
18461 SessionEnvironmentMaterializer,
18462 #[error("node bundle catalog is invalid: {0}")]
18463 BundleCatalog(String),
18464 #[error("node delivery store failed to initialize: {0}")]
18465 DeliveryStore(String),
18466 #[error("node context pack store failed to initialize")]
18467 ContextPackStore,
18468 #[error("active agent registry failed: {0}")]
18469 Registry(String),
18470 #[error("node provider contract manifest is invalid: {0}")]
18471 ProviderContractManifest(String),
18472 #[error("named pipe I/O failed: {0}")]
18473 Io(#[from] io::Error),
18474 #[error("node HTTP observer failed: {0}")]
18475 HttpApi(io::Error),
18476 #[error("node browser bridge failed: {0}")]
18477 BridgeApi(io::Error),
18478 #[error(transparent)]
18479 Frame(#[from] FrameError),
18480 #[error("node handshake failed: {0}")]
18481 Handshake(String),
18482 #[error("node authentication frame was not received before the bounded deadline")]
18483 AuthenticationTimedOut,
18484 #[error("node authentication primitive failed: {0}")]
18485 Authentication(String),
18486 #[error("node incarnation identity generation failed: {0}")]
18487 IncarnationIdentity(String),
18488 #[error("node pre-authentication limiter is closed")]
18489 PreauthClosed,
18490 #[error("node authenticated connection limit was reached")]
18491 AuthenticatedConnectionLimit,
18492 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18493 SignalStreamClosed,
18494 #[error("node stop-all dispatch failed: {0:?}")]
18495 ShutdownDispatch(NodeFailure),
18496 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18497 ShutdownTimedOut { active_native_sessions: usize },
18498}
18499
18500impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18501 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18502 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18503 match error {
18504 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18505 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18506 E::InvalidEntry { line, message } => {
18507 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18508 }
18509 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18510 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18511 }
18512 }
18513}
18514
18515#[cfg(test)]
18516mod agent_stream_blocked_tests {
18517 use super::*;
18518 use gate4agent_types::{
18519 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18520 ProviderSource,
18521 };
18522
18523 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18524 ProviderSource {
18525 family,
18526 binding: AdapterBinding::new(
18527 AdapterId::new(adapter).unwrap(),
18528 "fixture/v1",
18529 AdapterVerification::SyntheticFixture,
18530 )
18531 .unwrap(),
18532 }
18533 }
18534
18535 fn provider_control_event_at(
18536 family: AdapterFamily,
18537 adapter: &str,
18538 source_sequence: u64,
18539 provider_event: ProviderEvent,
18540 ) -> ControlEvent {
18541 ControlEvent {
18542 sequence: 41,
18543 command_id: None,
18544 instance_id: AgentInstanceId(7),
18545 generation: SessionGeneration(3),
18546 event: ControlEventKind::ProviderEvent {
18547 sequence: 8,
18548 source: provider_source(family, adapter),
18549 source_sequence,
18550 event: provider_event,
18551 },
18552 }
18553 }
18554
18555 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18556 provider_control_event_at(family, "fixture", 9, provider_event)
18557 }
18558
18559 fn denied_host_request(
18560 by: ProviderHostDecisionAuthority,
18561 reason: Option<String>,
18562 ) -> ControlEvent {
18563 provider_control_event_at(
18564 AdapterFamily::Acp,
18565 "claude-code",
18566 2,
18567 ProviderEvent::HostRequestObserved {
18568 method: "terminal/create".to_owned(),
18569 params_json: String::new(),
18570 decision: ProviderHostRequestDecision::Denied { by },
18571 outcome: ProviderHostRequestOutcome::Executed,
18572 reason,
18573 },
18574 )
18575 }
18576
18577 fn blocked(
18578 event: &ControlEvent,
18579 ) -> (
18580 Option<String>,
18581 String,
18582 BlockAuthorityV1,
18583 Option<String>,
18584 String,
18585 Option<String>,
18586 ) {
18587 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18588 chunk.validate().expect("minted chunk must validate");
18589 let AgentStreamChunkKindV1::Blocked {
18590 correlation_id,
18591 tool_class,
18592 authority,
18593 reason_kind,
18594 reason,
18595 help,
18596 } = chunk.kind
18597 else {
18598 panic!("expected AgentStreamChunkKindV1::Blocked");
18599 };
18600 (correlation_id, tool_class, authority, reason_kind, reason, help)
18601 }
18602
18603 #[test]
18607 fn the_enabled_provider_matrix_is_exact() {
18608 let registry = active_registry().unwrap();
18609 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18610 assert_eq!(
18611 providers
18612 .iter()
18613 .map(|contract| contract.provider.as_str())
18614 .collect::<Vec<_>>(),
18615 vec!["shell", "claude", "codex", "grok", "kimi"],
18617 );
18618 assert!(adapters.iter().all(|contract| {
18619 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18620 }));
18621 }
18622
18623 #[test]
18626 fn gate_denied_host_request_mints_a_blocked_chunk() {
18627 let gate_text =
18628 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18629 let event = denied_host_request(
18630 ProviderHostDecisionAuthority::Gate,
18631 Some(gate_text.to_owned()),
18632 );
18633 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18634 assert_eq!(chunk.source_sequence, 2);
18635 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18636 assert_eq!(authority, BlockAuthorityV1::HostGate);
18637 assert_eq!(reason, gate_text);
18638 assert_eq!(correlation_id, None);
18639 assert_eq!(reason_kind, None);
18640 assert_eq!(help, None);
18641 assert_eq!(tool_class, "Write");
18642 }
18643
18644 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.";
18645
18646 #[test]
18652 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18653 let event = provider_control_event(
18654 AdapterFamily::Acp,
18655 ProviderEvent::ToolCompleted {
18656 id: "toolu_9".to_owned(),
18657 output: CLASSIFIER_SAMPLE.to_owned(),
18658 is_error: true,
18659 duration_ms: None,
18660 agent_id: None,
18661 non_execution_kind: Some("permission-rule".to_owned()),
18662 },
18663 );
18664 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18665 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18666 assert_eq!(reason, "Blocked by classifier");
18667 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18668 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18669 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18670 }
18671
18672 #[test]
18677 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18678 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.";
18679 let event = provider_control_event(
18680 AdapterFamily::Acp,
18681 ProviderEvent::SessionEnded {
18682 result: "JSON-RPC error -32603: Internal error".to_owned(),
18683 cost_usd: None,
18684 is_error: true,
18685 stop_reason: Some(ProviderStopReason::ProviderError {
18686 code: -32603,
18687 message: message.to_owned(),
18688 vendor_code: Some("usageLimitExceeded".to_owned()),
18689 }),
18690 },
18691 );
18692 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18693 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18694 assert_eq!(reason, message);
18695 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18696 assert_eq!(tool_class, "Session");
18697 }
18698
18699 #[test]
18702 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18703 let event = provider_control_event(
18704 AdapterFamily::Acp,
18705 ProviderEvent::SessionEnded {
18706 result: "refusal".to_owned(),
18707 cost_usd: None,
18708 is_error: true,
18709 stop_reason: Some(ProviderStopReason::Refusal),
18710 },
18711 );
18712 let (_, _, authority, _, _, _) = blocked(&event);
18713 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18714 }
18715
18716 #[test]
18720 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18721 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18722 let event = provider_control_event(
18723 AdapterFamily::Acp,
18724 ProviderEvent::ToolCompleted {
18725 id: "toolu_1".to_owned(),
18726 output: "command exited with status 1".to_owned(),
18727 is_error: true,
18728 duration_ms: Some(12),
18729 agent_id: None,
18730 non_execution_kind,
18731 },
18732 );
18733 assert!(agent_stream_chunk(&event).is_none());
18734 }
18735 let narrative = provider_control_event(
18736 AdapterFamily::Acp,
18737 ProviderEvent::ToolCompleted {
18738 id: "toolu_2".to_owned(),
18739 output: "the harness blocked the write, approval needed".to_owned(),
18740 is_error: false,
18741 duration_ms: Some(3),
18742 agent_id: None,
18743 non_execution_kind: None,
18744 },
18745 );
18746 assert!(agent_stream_chunk(&narrative).is_none());
18747 }
18748
18749 #[test]
18753 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18754 for (decision, outcome) in [
18755 (
18756 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18757 ProviderHostRequestOutcome::Executed,
18758 ),
18759 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18760 (
18761 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18762 ProviderHostRequestOutcome::Failed {
18763 error: "terminal/create spawn failed: os error 3".to_owned(),
18764 },
18765 ),
18766 ] {
18767 let event = provider_control_event_at(
18768 AdapterFamily::Acp,
18769 "claude-code",
18770 3,
18771 ProviderEvent::HostRequestObserved {
18772 method: "fs/read_text_file".to_owned(),
18773 params_json: String::new(),
18774 decision: decision.clone(),
18775 outcome,
18776 reason: None,
18777 },
18778 );
18779 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18780 }
18781 }
18782
18783 #[test]
18787 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18788 for (by, expected_authority, expected_reason) in [
18789 (
18790 ProviderHostDecisionAuthority::Operator,
18791 BlockAuthorityV1::Operator,
18792 "declined by the operator",
18793 ),
18794 (
18795 ProviderHostDecisionAuthority::DeadlinePolicy,
18796 BlockAuthorityV1::HostDeadline,
18797 "denied by host policy after the operator deadline expired",
18798 ),
18799 ] {
18800 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18801 assert_eq!(authority, expected_authority);
18802 assert_eq!(reason, expected_reason);
18803 }
18804 }
18805
18806 #[test]
18810 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18811 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18812 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18813 ProviderHostDecisionAuthority::Policy,
18814 Some(oversized),
18815 ));
18816 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18817 }
18818}
18819
18820#[cfg(test)]
18821mod standalone_capability_platform_tests {
18822 use super::*;
18823
18824 #[test]
18825 fn standalone_workspace_capability_requires_windows_identity_guards() {
18826 let capabilities = baseline_capabilities().unwrap();
18827 let advertised = capabilities.iter().any(|capability| {
18828 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18829 });
18830 assert_eq!(advertised, cfg!(windows));
18831
18832 let request = NodeRequest::CreateStandaloneWorkspace {
18833 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18834 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18835 initial_branch: None,
18836 };
18837 assert_eq!(
18838 request_uses_unnegotiated_capability(&request, &capabilities),
18839 !cfg!(windows),
18840 );
18841 }
18842}
18843
18844
18845#[cfg(test)]
18846mod station_knobs_resolve_tests {
18847 use super::*;
18848 use gate4agent_catalog::EnvMutation;
18849 use gate4agent_runtime_native::{
18850 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18851 NativeLaunchProfileId,
18852 };
18853 use std::ffi::OsString;
18854 use std::sync::Arc;
18855
18856 struct EmptyEnvironmentResolver;
18857
18858 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18859 fn resolve_child_environment(
18860 &self,
18861 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18862 Ok(vec![EnvMutation {
18863 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18864 value: None,
18865 }])
18866 }
18867 }
18868
18869 fn agent(value: &str) -> AgentId {
18870 AgentId::new(value).unwrap()
18871 }
18872
18873 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18874 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18875 let profile_revision =
18876 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18877 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18878 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18879 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18880 provider: agent("claude"),
18881 mode: SessionMode::Pty,
18882 terminal_size: gate4agent_types::TerminalSize {
18883 rows: 24,
18884 columns: 80,
18885 },
18886 prompt: None,
18887 bundle_id: None,
18888 context_id: None,
18889 environment_profile_id: Some(profile_id.clone()),
18890 };
18891 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18892 let catalog = active_registry().unwrap();
18893 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18894 let control = runtime.native_launch_profile_control();
18895 let node_profile = NodeEnvironmentProfile::new(
18896 profile_id.clone(),
18897 profile_revision,
18898 agent("claude"),
18899 [NativeLaunchProfile::new(
18900 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18901 agent("claude"),
18902 TransportKind::Pty,
18903 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18904 Arc::new(EmptyEnvironmentResolver),
18905 )
18906 .unwrap()],
18907 )
18908 .unwrap();
18909 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18910 for native_profile in native_profiles {
18911 runtime.upsert_native_launch_profile(native_profile).unwrap();
18912 }
18913 let workspace = WorkspaceConfig::new(
18914 WorkspaceId::new("primary").unwrap(),
18915 std::env::current_dir().unwrap(),
18916 )
18917 .unwrap();
18918 let shared = NodeShared::new_with_incarnation(
18919 handle,
18920 "fixture-token".to_owned(),
18921 NodeId::new("node-station-knobs-test").unwrap(),
18922 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18923 vec![workspace],
18924 vec![agent("claude")],
18925 ProviderRuntimeStatuses::default(),
18926 None,
18927 Vec::new(),
18928 Vec::new(),
18929 spawn_profiles,
18930 Some(control),
18931 None,
18932 None,
18933 None,
18934 Vec::new(),
18935 Vec::new(),
18936 Vec::new(),
18937 Vec::new(),
18938 None,
18939 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18940 );
18941 shared
18942 .environment_profiles
18943 .write()
18944 .unwrap_or_else(|poisoned| poisoned.into_inner())
18945 .insert(binding.id.clone(), binding);
18946 (shared, spawn_defaults)
18947 }
18948
18949 #[test]
18952 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18953 let (shared, defaults) = station_knobs_fixture();
18954 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18955 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18956 let spec = crate::protocol::SpawnSpec {
18957 target: crate::protocol::SpawnTarget {
18958 node_id: shared.node_id.clone(),
18959 workspace_id: WorkspaceId::new("primary").unwrap(),
18960 worktree_id: None,
18961 },
18962 profile_id: defaults.profile_id.clone(),
18963 expected_profile_revision: defaults.revision.clone(),
18964 overrides: crate::protocol::SpawnOverrides {
18965 network_allowlist: Some(unknown),
18966 browser_profile_id: Some(browser),
18967 ..crate::protocol::SpawnOverrides::default()
18968 },
18969 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18970 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18971 "station-knobs-unknown-allowlist",
18972 )
18973 .unwrap(),
18974 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18975 };
18976 let resolved = spec.resolve(&defaults).unwrap();
18977 let failure = shared
18978 .resolve_environment_profile(&resolved)
18979 .expect_err("unknown allowlist must refuse");
18980 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18981 }
18982
18983 #[test]
18989 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18990 let (shared, defaults) = station_knobs_fixture();
18991 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18992 #[cfg(not(feature = "dig2-station-probe"))]
18993 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18994 shared
18995 .network_allowlist_catalog
18996 .write()
18997 .unwrap_or_else(|poisoned| poisoned.into_inner())
18998 .insert_id(allowlist.clone())
18999 .unwrap();
19000 let spec = crate::protocol::SpawnSpec {
19001 target: crate::protocol::SpawnTarget {
19002 node_id: shared.node_id.clone(),
19003 workspace_id: WorkspaceId::new("primary").unwrap(),
19004 worktree_id: None,
19005 },
19006 profile_id: defaults.profile_id.clone(),
19007 expected_profile_revision: defaults.revision.clone(),
19008 overrides: crate::protocol::SpawnOverrides {
19009 network_allowlist: Some(allowlist.clone()),
19010 #[cfg(not(feature = "dig2-station-probe"))]
19011 browser_profile_id: Some(browser.clone()),
19012 ..crate::protocol::SpawnOverrides::default()
19013 },
19014 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19015 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
19016 .unwrap(),
19017 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19018 };
19019 let resolved = spec.resolve(&defaults).unwrap();
19020 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
19021 #[cfg(not(feature = "dig2-station-probe"))]
19022 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
19023 let receipt = shared
19024 .resolve_environment_profile(&resolved)
19025 .expect("resolve ok")
19026 .expect("env profile present");
19027 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19028 #[cfg(not(feature = "dig2-station-probe"))]
19029 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19030 #[cfg(feature = "dig2-station-probe")]
19031 assert_eq!(receipt.browser_profile_id, None);
19032 assert_eq!(receipt.profile_id.as_str(), "local-claude");
19033
19034 let omitted = crate::protocol::SpawnSpec {
19035 overrides: crate::protocol::SpawnOverrides::default(),
19036 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
19037 .unwrap(),
19038 ..spec
19039 };
19040 let resolved_omit = omitted.resolve(&defaults).unwrap();
19041 let bare = shared
19042 .resolve_environment_profile(&resolved_omit)
19043 .expect("resolve ok")
19044 .expect("env profile present");
19045 assert_eq!(bare.network_allowlist, None);
19046 assert_eq!(bare.browser_profile_id, None);
19047 }
19048
19049 #[cfg(feature = "dig2-station-probe")]
19054 #[test]
19055 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
19056 let (shared, defaults) = station_knobs_fixture();
19057 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19058 let spec = crate::protocol::SpawnSpec {
19059 target: crate::protocol::SpawnTarget {
19060 node_id: shared.node_id.clone(),
19061 workspace_id: WorkspaceId::new("primary").unwrap(),
19062 worktree_id: None,
19063 },
19064 profile_id: defaults.profile_id.clone(),
19065 expected_profile_revision: defaults.revision.clone(),
19066 overrides: crate::protocol::SpawnOverrides {
19067 browser_profile_id: Some(browser),
19068 ..crate::protocol::SpawnOverrides::default()
19069 },
19070 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19071 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19072 "station-knobs-probe-refuse",
19073 )
19074 .unwrap(),
19075 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19076 };
19077 let resolved = spec.resolve(&defaults).unwrap();
19078 let failure = shared
19079 .resolve_environment_profile(&resolved)
19080 .expect_err("browser_profile with failing probe must refuse");
19081 #[cfg(windows)]
19082 {
19083 assert!(
19084 matches!(
19085 failure.code,
19086 NodeFailureCode::BrowserStationUnreachable
19087 | NodeFailureCode::BrowserStationProbeUnavailable
19088 ),
19089 "unexpected code {:?}",
19090 failure.code
19091 );
19092 }
19093 #[cfg(not(windows))]
19094 {
19095 assert_eq!(
19096 failure.code,
19097 NodeFailureCode::BrowserStationProbeUnavailable
19098 );
19099 }
19100 }
19101
19102 #[cfg(feature = "dig2-station-probe")]
19104 #[test]
19105 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19106 let (shared, defaults) = station_knobs_fixture();
19107 let spec = crate::protocol::SpawnSpec {
19108 target: crate::protocol::SpawnTarget {
19109 node_id: shared.node_id.clone(),
19110 workspace_id: WorkspaceId::new("primary").unwrap(),
19111 worktree_id: None,
19112 },
19113 profile_id: defaults.profile_id.clone(),
19114 expected_profile_revision: defaults.revision.clone(),
19115 overrides: crate::protocol::SpawnOverrides::default(),
19116 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19117 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19118 "station-knobs-probe-omit",
19119 )
19120 .unwrap(),
19121 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19122 };
19123 let resolved = spec.resolve(&defaults).unwrap();
19124 let receipt = shared
19125 .resolve_environment_profile(&resolved)
19126 .expect("omit browser_profile must not probe")
19127 .expect("env profile present");
19128 assert_eq!(receipt.browser_profile_id, None);
19129 }
19130
19131 #[cfg(feature = "dig2-station-probe")]
19135 #[test]
19136 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19137 let (shared, _defaults) = station_knobs_fixture();
19138 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19139 let workspace_id = WorkspaceId::new("primary").unwrap();
19140 let address_a = SessionAddress {
19141 workspace_id: workspace_id.clone(),
19142 session: SessionKey {
19143 instance_id: AgentInstanceId(101),
19144 generation: SessionGeneration(1),
19145 },
19146 };
19147 let address_b = SessionAddress {
19148 workspace_id: workspace_id.clone(),
19149 session: SessionKey {
19150 instance_id: AgentInstanceId(102),
19151 generation: SessionGeneration(1),
19152 },
19153 };
19154 let guard_a = crate::dig2_station_lease::acquire_lease(
19155 &shared.browser_station_leases,
19156 profile.clone(),
19157 address_a.session.instance_id,
19158 1,
19159 )
19160 .expect("first lease");
19161 let busy = crate::dig2_station_lease::acquire_lease(
19162 &shared.browser_station_leases,
19163 profile.clone(),
19164 address_b.session.instance_id,
19165 2,
19166 )
19167 .expect_err("second holder must refuse");
19168 assert!(matches!(
19169 busy,
19170 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19171 ));
19172 shared.bind_session_with_policy(
19174 &address_a,
19175 ProviderRuntimePolicy::raw_pty(),
19176 None,
19177 );
19178 guard_a.retain();
19179 assert!(shared.remove_binding(&address_a).is_some());
19180 crate::dig2_station_lease::acquire_lease(
19181 &shared.browser_station_leases,
19182 profile,
19183 address_b.session.instance_id,
19184 3,
19185 )
19186 .expect("reacquire after clear")
19187 .retain();
19188 assert!(
19189 crate::dig2_station_lease::release_lease_for_holder(
19190 &shared.browser_station_leases,
19191 address_b.session.instance_id,
19192 )
19193 );
19194 }
19195
19196 #[cfg(not(feature = "dig2-station-probe"))]
19199 #[test]
19200 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19201 let (shared, defaults) = station_knobs_fixture();
19202 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19203 let spec = crate::protocol::SpawnSpec {
19204 target: crate::protocol::SpawnTarget {
19205 node_id: shared.node_id.clone(),
19206 workspace_id: WorkspaceId::new("primary").unwrap(),
19207 worktree_id: None,
19208 },
19209 profile_id: defaults.profile_id.clone(),
19210 expected_profile_revision: defaults.revision.clone(),
19211 overrides: crate::protocol::SpawnOverrides {
19212 browser_profile_id: Some(browser.clone()),
19213 ..crate::protocol::SpawnOverrides::default()
19214 },
19215 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19216 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19217 .unwrap(),
19218 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19219 };
19220 let resolved = spec.resolve(&defaults).unwrap();
19221 let receipt = shared
19222 .resolve_environment_profile(&resolved)
19223 .expect("stub resolve")
19224 .expect("env profile");
19225 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19226 }
19227
19228
19229 #[test]
19232 fn network_allowlist_catalog_loads_from_temp_file() {
19233 let unique = std::time::SystemTime::now()
19234 .duration_since(std::time::UNIX_EPOCH)
19235 .unwrap()
19236 .as_nanos();
19237 let path = std::env::temp_dir().join(format!(
19238 "gate4agent-node-allowlist-config-{}-{unique}.txt",
19239 std::process::id(),
19240 ));
19241 std::fs::write(
19242 &path,
19243 "# opaque station allowlist ids\negress-default\nlab-egress\n",
19244 )
19245 .unwrap();
19246 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19247 .expect("temp catalog file must load");
19248 let _ = std::fs::remove_file(&path);
19249 assert_eq!(loaded.len(), 2);
19250 let (shared, defaults) = station_knobs_fixture();
19251 {
19252 let mut catalog = shared
19253 .network_allowlist_catalog
19254 .write()
19255 .unwrap_or_else(|poisoned| poisoned.into_inner());
19256 *catalog = loaded;
19257 }
19258 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19259 let spec = crate::protocol::SpawnSpec {
19260 target: crate::protocol::SpawnTarget {
19261 node_id: shared.node_id.clone(),
19262 workspace_id: WorkspaceId::new("primary").unwrap(),
19263 worktree_id: None,
19264 },
19265 profile_id: defaults.profile_id.clone(),
19266 expected_profile_revision: defaults.revision.clone(),
19267 overrides: crate::protocol::SpawnOverrides {
19268 network_allowlist: Some(allowlist.clone()),
19269 ..crate::protocol::SpawnOverrides::default()
19270 },
19271 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19272 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19273 "station-knobs-catalog-file",
19274 )
19275 .unwrap(),
19276 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19277 };
19278 let resolved = spec.resolve(&defaults).unwrap();
19279 let receipt = shared
19280 .resolve_environment_profile(&resolved)
19281 .expect("registered via file load")
19282 .expect("env profile present");
19283 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19284 }
19285
19286 #[test]
19289 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19290 let (shared, _defaults) = station_knobs_fixture();
19291 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19292 shared
19293 .network_allowlist_catalog
19294 .write()
19295 .unwrap_or_else(|poisoned| poisoned.into_inner())
19296 .insert_id(allowlist.clone())
19297 .unwrap();
19298 let snapshot = shared.snapshot();
19299 let inventory = snapshot
19300 .launch_inventory
19301 .expect("launch inventory present");
19302 let ids = inventory
19303 .network_allowlists
19304 .expect("non-empty catalog surfaces ids");
19305 assert_eq!(ids, vec![allowlist]);
19306 }
19307
19308 #[test]
19312 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19313 let (shared, defaults) = station_knobs_fixture();
19314 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19315 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19316 id: allowlist.clone(),
19317 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19318 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19319 peer: "127.0.0.1:443".to_owned(),
19320 }],
19321 provider_native: Some(
19322 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19323 codex_network_access: Some(true),
19324 },
19325 ),
19326 };
19327 shared
19328 .network_allowlist_catalog
19329 .write()
19330 .unwrap_or_else(|poisoned| poisoned.into_inner())
19331 .insert(entry)
19332 .unwrap();
19333 let spec = crate::protocol::SpawnSpec {
19334 target: crate::protocol::SpawnTarget {
19335 node_id: shared.node_id.clone(),
19336 workspace_id: WorkspaceId::new("primary").unwrap(),
19337 worktree_id: None,
19338 },
19339 profile_id: defaults.profile_id.clone(),
19340 expected_profile_revision: defaults.revision.clone(),
19341 overrides: crate::protocol::SpawnOverrides {
19342 network_allowlist: Some(allowlist),
19343 ..crate::protocol::SpawnOverrides::default()
19344 },
19345 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19346 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19347 "station-knobs-unsupported-mapping",
19348 )
19349 .unwrap(),
19350 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19351 };
19352 let resolved = spec.resolve(&defaults).unwrap();
19353 assert_eq!(resolved.provider.as_str(), "claude");
19354 let failure = shared
19355 .resolve_environment_profile(&resolved)
19356 .expect_err("claude + codex_network_access must refuse");
19357 assert_eq!(
19358 failure.code,
19359 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19360 );
19361 }
19362
19363 #[test]
19367 fn resolve_codex_network_access_overlay_moderate_only() {
19368 let (shared, defaults) = station_knobs_fixture();
19369 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19370 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19371 id: allowlist.clone(),
19372 permits: Vec::new(),
19373 provider_native: Some(
19374 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19375 codex_network_access: Some(true),
19376 },
19377 ),
19378 };
19379 shared
19380 .network_allowlist_catalog
19381 .write()
19382 .unwrap_or_else(|poisoned| poisoned.into_inner())
19383 .insert(entry)
19384 .unwrap();
19385
19386 let overlay = shared
19387 .resolve_codex_network_access_overlay_args(
19388 &agent("codex"),
19389 SessionMode::Pty,
19390 ApprovalLevel::Moderate,
19391 Some(&allowlist),
19392 )
19393 .expect("Moderate Codex maps network_access");
19394 assert_eq!(
19395 overlay,
19396 vec![
19397 std::ffi::OsString::from("-c"),
19398 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19399 ]
19400 );
19401
19402 let acp = shared
19403 .resolve_codex_network_access_overlay_args(
19404 &agent("codex"),
19405 SessionMode::Acp,
19406 ApprovalLevel::Moderate,
19407 Some(&allowlist),
19408 )
19409 .unwrap();
19410 assert_eq!(acp, overlay);
19411
19412 let inline = shared
19413 .resolve_codex_network_access_overlay_args(
19414 &agent("codex"),
19415 SessionMode::Inline,
19416 ApprovalLevel::Moderate,
19417 Some(&allowlist),
19418 )
19419 .unwrap_err();
19420 assert_eq!(
19421 inline.code,
19422 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19423 );
19424
19425 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19426 let spec = crate::protocol::SpawnSpec {
19427 target: crate::protocol::SpawnTarget {
19428 node_id: shared.node_id.clone(),
19429 workspace_id: WorkspaceId::new("primary").unwrap(),
19430 worktree_id: None,
19431 },
19432 profile_id: defaults.profile_id.clone(),
19433 expected_profile_revision: defaults.revision.clone(),
19434 overrides: crate::protocol::SpawnOverrides {
19435 provider: crate::protocol::SpawnOverride::Set {
19436 value: agent("codex"),
19437 },
19438 approval_level: Some(level),
19439 network_allowlist: Some(allowlist.clone()),
19440 ..crate::protocol::SpawnOverrides::default()
19441 },
19442 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19443 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19444 "codex-net-refuse-{}",
19445 match level {
19446 ApprovalLevel::ReadOnly => "ro",
19447 ApprovalLevel::FullAuto => "fa",
19448 _ => "other",
19449 }
19450 ))
19451 .unwrap(),
19452 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19453 };
19454 let resolved = spec.resolve(&defaults).unwrap();
19455 let failure = shared
19456 .resolve_environment_profile(&resolved)
19457 .expect_err("RO/FullAuto must refuse");
19458 assert_eq!(
19459 failure.code,
19460 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19461 );
19462 }
19463 }
19464
19465 #[test]
19469 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19470 let (shared, _defaults) = station_knobs_fixture();
19471 let allowlist =
19472 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19473 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19474 id: allowlist.clone(),
19475 permits: vec![
19476 crate::network_allowlist_catalog::NetworkPermitSketch {
19477 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19478 peer: "github.com:443".to_owned(),
19479 },
19480 crate::network_allowlist_catalog::NetworkPermitSketch {
19481 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19482 peer: "*.npmjs.org".to_owned(),
19483 },
19484 ],
19485 provider_native: None,
19486 };
19487 shared
19488 .network_allowlist_catalog
19489 .write()
19490 .unwrap_or_else(|poisoned| poisoned.into_inner())
19491 .insert(entry)
19492 .unwrap();
19493
19494 let unique = std::time::SystemTime::now()
19495 .duration_since(std::time::UNIX_EPOCH)
19496 .unwrap()
19497 .as_nanos();
19498 let provider_home = std::env::temp_dir().join(format!(
19499 "gate4agent-claude-provider-home-{}-{unique}",
19500 std::process::id(),
19501 ));
19502 std::fs::create_dir_all(&provider_home).unwrap();
19503
19504 let overlay = shared
19505 .resolve_network_allowlist_overlay_args(
19506 &agent("claude"),
19507 SessionMode::Pty,
19508 ApprovalLevel::Moderate,
19509 Some(&allowlist),
19510 Some(provider_home.as_path()),
19511 )
19512 .expect("Claude PTY settings overlay on unix");
19513 let settings_path = provider_home.join(
19514 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19515 );
19516 assert_eq!(
19517 overlay,
19518 vec![
19519 std::ffi::OsString::from("--settings"),
19520 std::ffi::OsString::from(settings_path.as_os_str()),
19521 ]
19522 );
19523 let body = std::fs::read_to_string(&settings_path).unwrap();
19524 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19525 assert_eq!(value["sandbox"]["enabled"], true);
19526 assert_eq!(
19527 value["sandbox"]["network"]["allowedDomains"],
19528 serde_json::json!(["github.com", "*.npmjs.org"])
19529 );
19530 assert!(settings_path.starts_with(&provider_home));
19531
19532 let acp = shared
19533 .resolve_network_allowlist_overlay_args(
19534 &agent("claude"),
19535 SessionMode::Acp,
19536 ApprovalLevel::Moderate,
19537 Some(&allowlist),
19538 Some(provider_home.as_path()),
19539 )
19540 .expect_err("Claude ACP must refuse settings overlay");
19541 assert_eq!(
19542 acp.code,
19543 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19544 );
19545
19546 let no_home = shared
19547 .resolve_network_allowlist_overlay_args(
19548 &agent("claude"),
19549 SessionMode::Pty,
19550 ApprovalLevel::Moderate,
19551 Some(&allowlist),
19552 None,
19553 )
19554 .expect_err("Claude overlay requires ProviderHome");
19555 assert_eq!(
19556 no_home.code,
19557 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19558 );
19559
19560 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19561 shared
19562 .network_allowlist_catalog
19563 .write()
19564 .unwrap_or_else(|poisoned| poisoned.into_inner())
19565 .insert_id(bare.clone())
19566 .unwrap();
19567 let empty = shared
19568 .resolve_network_allowlist_overlay_args(
19569 &agent("claude"),
19570 SessionMode::Pty,
19571 ApprovalLevel::Moderate,
19572 Some(&bare),
19573 Some(provider_home.as_path()),
19574 )
19575 .unwrap();
19576 assert!(empty.is_empty());
19577
19578 let _ = std::fs::remove_dir_all(&provider_home);
19579 }
19580
19581 #[test]
19583 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19584 let (shared, _defaults) = station_knobs_fixture();
19585 let allowlist =
19586 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19587 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19588 id: allowlist.clone(),
19589 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19590 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19591 peer: "example.com:443".to_owned(),
19592 }],
19593 provider_native: None,
19594 };
19595 shared
19596 .network_allowlist_catalog
19597 .write()
19598 .unwrap_or_else(|poisoned| poisoned.into_inner())
19599 .insert(entry)
19600 .unwrap();
19601 for provider in ["kimi", "grok"] {
19602 let overlay = shared
19603 .resolve_network_allowlist_overlay_args(
19604 &agent(provider),
19605 SessionMode::Pty,
19606 ApprovalLevel::Moderate,
19607 Some(&allowlist),
19608 None,
19609 )
19610 .unwrap();
19611 assert!(
19612 overlay.is_empty(),
19613 "{provider} must not invent network argv from permits"
19614 );
19615 }
19616 }
19617
19618 #[test]
19620 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19621 let unique = std::time::SystemTime::now()
19622 .duration_since(std::time::UNIX_EPOCH)
19623 .unwrap()
19624 .as_nanos();
19625 let path = std::env::temp_dir().join(format!(
19626 "gate4agent-node-allowlist-json-{}-{unique}.json",
19627 std::process::id(),
19628 ));
19629 std::fs::write(
19630 &path,
19631 r#"{
19632 "schema_version": 2,
19633 "entries": [
19634 {
19635 "id": "egress-default",
19636 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19637 },
19638 { "id": "lab-egress" }
19639 ]
19640 }"#,
19641 )
19642 .unwrap();
19643 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19644 .expect("json v2 catalog must load");
19645 let _ = std::fs::remove_file(&path);
19646 assert_eq!(loaded.len(), 2);
19647 let entry = loaded
19648 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19649 .unwrap();
19650 assert_eq!(entry.permits.len(), 1);
19651 let (shared, defaults) = station_knobs_fixture();
19652 {
19653 let mut catalog = shared
19654 .network_allowlist_catalog
19655 .write()
19656 .unwrap_or_else(|poisoned| poisoned.into_inner());
19657 *catalog = loaded;
19658 }
19659 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19660 let spec = crate::protocol::SpawnSpec {
19661 target: crate::protocol::SpawnTarget {
19662 node_id: shared.node_id.clone(),
19663 workspace_id: WorkspaceId::new("primary").unwrap(),
19664 worktree_id: None,
19665 },
19666 profile_id: defaults.profile_id.clone(),
19667 expected_profile_revision: defaults.revision.clone(),
19668 overrides: crate::protocol::SpawnOverrides {
19669 network_allowlist: Some(allowlist.clone()),
19670 ..crate::protocol::SpawnOverrides::default()
19671 },
19672 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19673 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19674 "station-knobs-catalog-json",
19675 )
19676 .unwrap(),
19677 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19678 };
19679 let resolved = spec.resolve(&defaults).unwrap();
19680 let receipt = shared
19681 .resolve_environment_profile(&resolved)
19682 .expect("permits-only entry ok for claude")
19683 .expect("env profile present");
19684 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19685 let inventory = shared
19686 .snapshot()
19687 .launch_inventory
19688 .expect("launch inventory present");
19689 let ids = inventory.network_allowlists.expect("ids surface");
19690 assert_eq!(
19691 ids,
19692 vec![
19693 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19694 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19695 ]
19696 );
19697 }
19698
19699
19700}
19701
19702
19703#[cfg(test)]
19704mod workspace_environment_collision_tests {
19705 use super::*;
19706 use crate::session_environment::{
19707 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19708 };
19709 use gate4agent_catalog::EnvMutation;
19710 use gate4agent_runtime_native::{
19711 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19712 NativeLaunchProfileId,
19713 };
19714 use std::ffi::OsString;
19715 use std::sync::Arc;
19716
19717 struct EmptyEnvironmentResolver;
19718
19719 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19720 fn resolve_child_environment(
19721 &self,
19722 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19723 Ok(vec![EnvMutation {
19724 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19725 value: None,
19726 }])
19727 }
19728 }
19729
19730 fn agent(value: &str) -> AgentId {
19731 AgentId::new(value).unwrap()
19732 }
19733
19734 fn claude_config_dir_collision_fixture() -> (
19736 NodeShared,
19737 ResolvedEnvironmentProfileReceipt,
19738 ResolvedEnvironmentProfileReceipt,
19739 ) {
19740 let catalog = active_registry().unwrap();
19741 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19742 let control = runtime.native_launch_profile_control();
19743 let workspace = WorkspaceConfig::new(
19744 WorkspaceId::new("primary").unwrap(),
19745 std::env::current_dir().unwrap(),
19746 )
19747 .unwrap();
19748 let shared = NodeShared::new_with_incarnation(
19749 handle,
19750 "fixture-token".to_owned(),
19751 NodeId::new("node-env-collision-test").unwrap(),
19752 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19753 vec![workspace],
19754 vec![agent("claude")],
19755 ProviderRuntimeStatuses::default(),
19756 None,
19757 Vec::new(),
19758 Vec::new(),
19759 SpawnProfileRegistry::default(),
19760 Some(control),
19761 None,
19762 None,
19763 None,
19764 Vec::new(),
19765 Vec::new(),
19766 Vec::new(),
19767 Vec::new(),
19768 None,
19769 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19770 );
19771
19772 let mut receipts = Vec::new();
19773 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19774 let profile_id =
19775 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19776 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19777 "claude-config-dir-{suffix}-r1"
19778 ))
19779 .unwrap();
19780 let materialization = NodeSessionMaterializationProfile::new(
19781 Vec::new(),
19782 vec![NodeSessionPathBinding::new(
19783 "CLAUDE_CONFIG_DIR",
19784 NodeSessionPathClass::ProviderHome,
19785 )
19786 .unwrap()],
19787 Vec::new(),
19788 )
19789 .unwrap();
19790 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19791 profile_id.clone(),
19792 profile_revision.clone(),
19793 agent("claude"),
19794 [NativeLaunchProfile::new(
19795 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19796 .unwrap(),
19797 agent("claude"),
19798 TransportKind::Pty,
19799 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19800 Arc::new(EmptyEnvironmentResolver),
19801 )
19802 .unwrap()],
19803 Some(materialization),
19804 )
19805 .unwrap();
19806 let (binding, native_profiles, materialization) = node_profile.into_parts();
19807 for native_profile in native_profiles {
19808 runtime.upsert_native_launch_profile(native_profile).unwrap();
19809 }
19810 shared
19811 .environment_profiles
19812 .write()
19813 .unwrap_or_else(|poisoned| poisoned.into_inner())
19814 .insert(binding.id.clone(), binding);
19815 shared
19816 .environment_materialization_profiles
19817 .write()
19818 .unwrap_or_else(|poisoned| poisoned.into_inner())
19819 .insert(profile_id.clone(), materialization.unwrap());
19820 receipts.push(ResolvedEnvironmentProfileReceipt {
19821 profile_id,
19822 profile_revision,
19823 network_allowlist: None,
19824 browser_profile_id: None,
19825 });
19826 }
19827 let profile_b = receipts.pop().unwrap();
19828 let profile_a = receipts.pop().unwrap();
19829 (shared, profile_a, profile_b)
19830 }
19831
19832 #[test]
19833 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19834 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19835 let primary = WorkspaceId::new("primary").unwrap();
19836 let worktree = WorkspaceId::new("worktree-b").unwrap();
19837 let address = SessionAddress {
19838 workspace_id: primary.clone(),
19839 session: SessionKey {
19840 instance_id: AgentInstanceId(41),
19841 generation: SessionGeneration(1),
19842 },
19843 };
19844 shared.bind_session_with_policy(
19845 &address,
19846 ProviderRuntimePolicy::raw_pty(),
19847 Some(profile_a.clone()),
19848 );
19849
19850 let refused = shared
19851 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19852 .unwrap_err();
19853 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19854
19855 let refused_none = shared
19857 .ensure_workspace_environment_profile_compatible(&primary, None)
19858 .unwrap_err();
19859 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19860
19861 shared
19863 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19864 .unwrap();
19865
19866 shared
19868 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19869 .unwrap();
19870 }
19871
19872 #[tokio::test]
19873 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19874 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19875 let primary = WorkspaceId::new("primary").unwrap();
19876 let address = SessionAddress {
19877 workspace_id: primary.clone(),
19878 session: SessionKey {
19879 instance_id: AgentInstanceId(41),
19880 generation: SessionGeneration(1),
19881 },
19882 };
19883 shared.bind_session_with_policy(
19884 &address,
19885 ProviderRuntimePolicy::raw_pty(),
19886 Some(profile_a),
19887 );
19888
19889 let failure = shared
19890 .spawn_session_with_deadline(
19891 primary,
19892 agent("claude"),
19893 SessionMode::Pty,
19894 gate4agent_types::TerminalSize {
19895 rows: 24,
19896 columns: 80,
19897 },
19898 None,
19899 Some(profile_b),
19900 None,
19901 None,
19902 None,
19903 None,
19904 SpawnRecordPolicy::ProviderIdentityOnly,
19905 Some(Instant::now() + Duration::from_secs(30)),
19906 &[],
19907 None,
19908 ApprovalLevel::default(),
19909 None,
19910 None,
19911 )
19912 .await
19913 .unwrap_err();
19914 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19915 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19917 assert!(shared.handle.snapshot().sessions.is_empty());
19918 }
19919}
19920
19921#[cfg(test)]
19922mod spawn_characterization_tests {
19923 use super::*;
19924 use crate::session_environment::{
19925 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19926 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19927 NodeSessionPathClass,
19928 };
19929 use gate4agent_catalog::EnvMutation;
19930 use gate4agent_runtime_native::{
19931 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19932 NativeLaunchProfile, NativeLaunchProfileId,
19933 };
19934 use std::ffi::OsString;
19935 use std::sync::Arc;
19936
19937 struct EmptyEnvironmentResolver;
19938
19939 struct FixtureSecretResolver {
19940 deny: Arc<AtomicBool>,
19941 }
19942
19943 impl NodeSecretResolver for FixtureSecretResolver {
19944 fn resolve(
19945 &self,
19946 _reference: &NodeSecretReference,
19947 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19948 if self.deny.load(Ordering::Acquire) {
19949 Err(NodeSecretResolveError::Denied)
19950 } else {
19951 NodeSecretValue::text("fixture-secret")
19952 .map_err(|_| NodeSecretResolveError::Unavailable)
19953 }
19954 }
19955 }
19956
19957 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19958 fn resolve_child_environment(
19959 &self,
19960 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19961 Ok(vec![EnvMutation {
19962 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19963 value: None,
19964 }])
19965 }
19966 }
19967
19968 fn agent(value: &str) -> AgentId {
19969 AgentId::new(value).unwrap()
19970 }
19971
19972 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19973 let unique = std::time::SystemTime::now()
19974 .duration_since(std::time::UNIX_EPOCH)
19975 .unwrap()
19976 .as_nanos();
19977 std::env::temp_dir().join(format!(
19978 "gate4agent-node-{label}-{}-{unique}",
19979 std::process::id(),
19980 ))
19981 }
19982
19983 fn materialization_test_shared(
19984 deny_secret: bool,
19985 ) -> (
19986 NodeShared,
19987 NativeRuntime,
19988 ResolvedEnvironmentProfileReceipt,
19989 PathBuf,
19990 Arc<AtomicBool>,
19991 ) {
19992 let workspace = WorkspaceConfig::new(
19993 WorkspaceId::new("primary").unwrap(),
19994 std::env::current_dir().unwrap(),
19995 )
19996 .unwrap();
19997 materialization_test_shared_with_workspace(deny_secret, workspace)
19998 }
19999
20000 fn materialization_test_shared_with_workspace(
20001 deny_secret: bool,
20002 workspace: WorkspaceConfig,
20003 ) -> (
20004 NodeShared,
20005 NativeRuntime,
20006 ResolvedEnvironmentProfileReceipt,
20007 PathBuf,
20008 Arc<AtomicBool>,
20009 ) {
20010 let root = temporary_workspace_root(if deny_secret {
20011 "materialization-denied"
20012 } else {
20013 "materialization-ready"
20014 });
20015 std::fs::create_dir_all(&root).unwrap();
20016 let materialization_root = root.join("session-environments");
20017 let deny = Arc::new(AtomicBool::new(deny_secret));
20018 let resolver = Arc::new(FixtureSecretResolver {
20019 deny: Arc::clone(&deny),
20020 });
20021 let materializer = SessionEnvironmentMaterializer::new(
20022 materialization_root,
20023 resolver,
20024 )
20025 .unwrap();
20026 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20027 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20028 "materialized-claude-r1",
20029 )
20030 .unwrap();
20031 let materialization = NodeSessionMaterializationProfile::new(
20032 vec![NodeSessionEnvironmentMutation::SetSecret {
20033 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20034 reference: NodeSecretReference::new("fixture-token").unwrap(),
20035 }],
20036 vec![NodeSessionPathBinding::new(
20037 "GATE4AGENT_PROVIDER_HOME",
20038 NodeSessionPathClass::ProviderHome,
20039 )
20040 .unwrap()],
20041 vec![NodeSessionFile::secret(
20042 NodeSessionPathClass::Config,
20043 "auth/token",
20044 NodeSecretReference::new("fixture-token").unwrap(),
20045 )
20046 .unwrap()],
20047 )
20048 .unwrap();
20049 let catalog = active_registry().unwrap();
20050 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20051 let control = runtime.native_launch_profile_control();
20052 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20053 profile_id.clone(),
20054 profile_revision.clone(),
20055 agent("claude"),
20056 [NativeLaunchProfile::new(
20057 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20058 agent("claude"),
20059 TransportKind::Pty,
20060 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20061 Arc::new(EmptyEnvironmentResolver),
20062 )
20063 .unwrap()],
20064 Some(materialization),
20065 )
20066 .unwrap();
20067 let (binding, native_profiles, materialization) = node_profile.into_parts();
20068 for native_profile in native_profiles {
20069 runtime.upsert_native_launch_profile(native_profile).unwrap();
20070 }
20071 let shared = NodeShared::new_with_incarnation(
20072 handle,
20073 "fixture-token".to_owned(),
20074 NodeId::new("node-materialization-test").unwrap(),
20075 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20076 vec![workspace],
20077 vec![agent("claude")],
20078 ProviderRuntimeStatuses::default(),
20079 None,
20080 Vec::new(),
20081 Vec::new(),
20082 SpawnProfileRegistry::default(),
20083 Some(control),
20084 None,
20085 Some(materializer),
20086 None,
20087 Vec::new(),
20088 Vec::new(),
20089 Vec::new(),
20090 Vec::new(),
20091 None,
20092 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20093 );
20094 shared
20095 .environment_profiles
20096 .write()
20097 .unwrap_or_else(|poisoned| poisoned.into_inner())
20098 .insert(binding.id.clone(), binding);
20099 shared
20100 .environment_materialization_profiles
20101 .write()
20102 .unwrap_or_else(|poisoned| poisoned.into_inner())
20103 .insert(profile_id.clone(), materialization.unwrap());
20104 (
20105 shared,
20106 runtime,
20107 ResolvedEnvironmentProfileReceipt {
20108 profile_id,
20109 profile_revision,
20110 network_allowlist: None,
20111 browser_profile_id: None,
20112 },
20113 root,
20114 deny,
20115 )
20116 }
20117
20118
20119 async fn characterization_spawn(
20122 shared: &NodeShared,
20123 mode: SessionMode,
20124 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20125 required: &[ProviderRuntimeCapability],
20126 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20127 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20128 shared
20129 .spawn_session_with_deadline(
20130 WorkspaceId::new("primary").unwrap(),
20131 agent("claude"),
20132 mode,
20133 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20134 None,
20135 environment_profile,
20136 None,
20137 None,
20138 None,
20139 Some(ProviderRuntimePolicy::raw_pty()),
20140 SpawnRecordPolicy::ProviderIdentityOnly,
20141 Some(Instant::now() + Duration::from_secs(30)),
20142 required,
20143 harness_mcp,
20144 ApprovalLevel::default(),
20145 None,
20146 None,
20147 )
20148 .await
20149 }
20150
20151 fn assert_no_session_state(shared: &NodeShared) {
20152 assert!(shared.handle.snapshot().sessions.is_empty());
20153 assert!(shared
20154 .session_bindings
20155 .lock()
20156 .unwrap_or_else(|poisoned| poisoned.into_inner())
20157 .is_empty());
20158 assert!(shared
20159 .materializations
20160 .lock()
20161 .unwrap_or_else(|poisoned| poisoned.into_inner())
20162 .is_empty());
20163 }
20164
20165 #[tokio::test]
20166 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20167 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20168 let error = characterization_spawn(
20169 &shared,
20170 SessionMode::Pty,
20171 None,
20172 &[ProviderRuntimeCapability::SemanticReadiness],
20173 None,
20174 )
20175 .await
20176 .unwrap_err();
20177 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20178 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20179 assert_no_session_state(&shared);
20180 drop(shared);
20181 drop(runtime);
20182 std::fs::remove_dir_all(root).unwrap();
20183 }
20184
20185 #[tokio::test]
20186 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20187 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20188 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20189 .await
20190 .unwrap_err();
20191 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20192 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20193 assert_no_session_state(&shared);
20194 drop(shared);
20195 drop(runtime);
20196 std::fs::remove_dir_all(root).unwrap();
20197 }
20198
20199 #[test]
20200 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20201 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20202 let address = SessionAddress {
20203 workspace_id: WorkspaceId::new("primary").unwrap(),
20204 session: SessionKey {
20205 instance_id: AgentInstanceId(77),
20206 generation: SessionGeneration(1),
20207 },
20208 };
20209 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20210 let removed = shared.remove_binding(&address).expect("binding removed");
20211 assert!(removed.environment_profile.is_some());
20212 assert!(shared.remove_binding(&address).is_none());
20213 assert_no_session_state(&shared);
20214 drop(shared);
20215 drop(runtime);
20216 std::fs::remove_dir_all(root).unwrap();
20217 }
20218
20219 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20220 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20221 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20222 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20223 .expect("review current test executable as fixture helper");
20224 let registry = HarnessMcpProxyRegistry::new(helper);
20225 let spec = crate::protocol::SpawnSpec {
20226 target: crate::protocol::SpawnTarget {
20227 node_id: NodeId::new("node-characterization").unwrap(),
20228 workspace_id: WorkspaceId::new("primary").unwrap(),
20229 worktree_id: None,
20230 },
20231 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20232 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20233 overrides: crate::protocol::SpawnOverrides::default(),
20234 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20235 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20236 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20237 };
20238 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20239 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20240 let launch = HarnessMcpLaunchV1 {
20241 server_name: "fixture-harness".to_owned(),
20242 args: vec!["--session-proxy".to_owned()],
20243 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20244 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20245 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20246 trace: None,
20247 scrub_env: Vec::new(),
20248 };
20249 let expires = unix_time_ms().saturating_add(30_000);
20250 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20251 registry
20252 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20253 .unwrap()
20254 }
20255
20256 #[tokio::test]
20257 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20258 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20259 let prepared = characterization_prepared_harness("claude").await;
20260 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20261 .await
20262 .unwrap_err();
20263 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20265 let mismatched = characterization_prepared_harness("codex").await;
20266 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20267 .await
20268 .unwrap_err();
20269 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20270 assert_no_session_state(&shared);
20271 drop(shared);
20272 drop(runtime);
20273 std::fs::remove_dir_all(root).unwrap();
20274 }
20275
20276 #[tokio::test]
20277 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20278 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20279 shared.native_launch_profile_control = None;
20280 let prepared = characterization_prepared_harness("claude").await;
20281 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20282 .await
20283 .unwrap_err();
20284 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20285 assert_no_session_state(&shared);
20286 drop(shared);
20287 drop(runtime);
20288 std::fs::remove_dir_all(root).unwrap();
20289 }
20290}
20291
20292#[cfg(all(test, windows))]
20293mod tests {
20294 use super::*;
20295 use gate4agent_types::ProviderActivity;
20296 use crate::session_environment::{
20297 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20298 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20299 NodeSessionPathClass,
20300 };
20301 use gate4agent_catalog::EnvMutation;
20302 use gate4agent_runtime_native::{
20303 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20304 NativeLaunchProfile, NativeLaunchProfileId,
20305 };
20306 use std::ffi::OsString;
20307 use std::sync::Arc;
20308
20309 struct EmptyEnvironmentResolver;
20310
20311 struct FixtureSecretResolver {
20312 deny: Arc<AtomicBool>,
20313 }
20314
20315 impl NodeSecretResolver for FixtureSecretResolver {
20316 fn resolve(
20317 &self,
20318 _reference: &NodeSecretReference,
20319 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20320 if self.deny.load(Ordering::Acquire) {
20321 Err(NodeSecretResolveError::Denied)
20322 } else {
20323 NodeSecretValue::text("fixture-secret")
20324 .map_err(|_| NodeSecretResolveError::Unavailable)
20325 }
20326 }
20327 }
20328
20329 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20330 fn resolve_child_environment(
20331 &self,
20332 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20333 Ok(vec![EnvMutation {
20334 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20335 value: None,
20336 }])
20337 }
20338 }
20339
20340 fn agent(value: &str) -> AgentId {
20341 AgentId::new(value).unwrap()
20342 }
20343
20344 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20345 let catalog = active_registry().unwrap();
20346 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20347 let workspace = WorkspaceConfig::new(
20348 WorkspaceId::new("primary").unwrap(),
20349 std::env::current_dir().unwrap(),
20350 )
20351 .unwrap()
20352 .with_worktree_service_mode(mode);
20353 NodeShared::new(
20354 handle,
20355 "fixture-token".to_owned(),
20356 NodeId::new("node-terminal-test").unwrap(),
20357 vec![workspace],
20358 vec![agent("claude")],
20359 )
20360 }
20361
20362 fn terminal_test_shared() -> NodeShared {
20363 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20364 }
20365
20366 #[cfg(feature = "fixture")]
20367 #[test]
20368 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20369 let probe = SpawnManagedWorktreeV2FailureProbe::default();
20370 assert_eq!(probe.latest_code(), None);
20371
20372 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20373 assert_eq!(
20374 probe.latest_code(),
20375 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20376 );
20377
20378 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20379 assert_eq!(
20380 probe.latest_code(),
20381 Some(NodeFailureCode::SpawnDeadlineExceeded),
20382 );
20383
20384 probe.record(None);
20385 assert_eq!(probe.latest_code(), None);
20386 }
20387
20388 #[test]
20389 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20390 let source = temporary_workspace_root("launch-inventory-source");
20391 let allocation = temporary_workspace_root("launch-inventory-allocation");
20392 std::fs::create_dir_all(&source).unwrap();
20393 std::fs::create_dir_all(&allocation).unwrap();
20394 let workspace_id = WorkspaceId::new("primary").unwrap();
20395 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20396 .unwrap()
20397 .with_worktree_service_mode(WorktreeServiceMode::Managed);
20398 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20399 workspace = workspace
20400 .with_managed_worktree_profile(
20401 ManagedWorktreeProfile::new(
20402 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20403 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20404 &allocation,
20405 "gate4agent",
20406 "HEAD",
20407 ManagedWorktreeRetention::RemoveWhenReleased,
20408 )
20409 .unwrap(),
20410 )
20411 .unwrap();
20412 }
20413 let overflow = workspace.clone().with_managed_worktree_profile(
20414 ManagedWorktreeProfile::new(
20415 WorktreeProfileId::new("overflow").unwrap(),
20416 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20417 &allocation,
20418 "gate4agent",
20419 "HEAD",
20420 ManagedWorktreeRetention::Retain,
20421 )
20422 .unwrap(),
20423 );
20424 assert!(matches!(
20425 overflow,
20426 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20427 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20428 ..
20429 }),
20430 ));
20431
20432 let catalog = active_registry().unwrap();
20433 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20434 let shared = NodeShared::new(
20435 handle,
20436 "fixture-token".to_owned(),
20437 NodeId::new("node-launch-inventory").unwrap(),
20438 vec![workspace],
20439 vec![agent("claude")],
20440 );
20441 let snapshot = shared.snapshot();
20442 let launch = snapshot.launch_inventory.as_ref().unwrap();
20443 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20444 assert!(launch.bundles.as_ref().unwrap().is_empty());
20445 let profiles = snapshot.workspaces[0]
20446 .managed_worktree_profiles
20447 .as_ref()
20448 .unwrap();
20449 assert_eq!(
20450 snapshot.workspaces[0].worktree_service_mode,
20451 Some(WorktreeServiceMode::Managed),
20452 );
20453 assert_eq!(
20454 profiles.profiles.len(),
20455 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20456 );
20457 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20458 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20459 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20460 }
20461 let legacy = snapshot_for_wire(
20462 &shared,
20463 true,
20464 true,
20465 false,
20466 false,
20467 true,
20468 false,
20469 true,
20470 false,
20471 true,
20472 );
20473 assert!(legacy.launch_inventory.is_none());
20474 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20475
20476 drop(shared);
20477 std::fs::remove_dir_all(source).unwrap();
20478 std::fs::remove_dir_all(allocation).unwrap();
20479 }
20480
20481 #[test]
20482 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20483 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20484 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20485 }));
20486 let source = gate4agent_types::ProviderSource {
20487 family: AdapterFamily::PtySemantic,
20488 binding: AdapterBinding::new(
20489 gate4agent_types::AdapterId::new("claude").unwrap(),
20490 "fixture/v1",
20491 gate4agent_types::AdapterVerification::SyntheticFixture,
20492 )
20493 .unwrap(),
20494 };
20495 let provider = ProviderSnapshot {
20496 sequence: 17,
20497 session: Some(ProviderSessionIdentity {
20498 key: ProviderSessionKey::SessionId,
20499 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20500 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20501 }),
20502 model: Some("PRIVATE_MODEL".to_owned()),
20503 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20504 completed_turns: 3,
20505 usage: gate4agent_types::TokenUsage {
20506 input_tokens: 10,
20507 output_tokens: 20,
20508 cache_read_tokens: 30,
20509 cache_write_tokens: 40,
20510 reasoning_tokens: 50,
20511 context_window: Some(200_000),
20512 },
20513 lead_activity: ProviderActivity::WaitingForInput,
20514 activity: ProviderActivity::WaitingForInput,
20515 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20516 active_tools: (0..9)
20517 .map(|index| gate4agent_types::ActiveProviderTool {
20518 id: format!("private-tool-id-{index}"),
20519 name: if index == 0 {
20520 format!(" Read\u{0000}{}", "x".repeat(80))
20521 } else {
20522 format!("Tool-{index}")
20523 },
20524 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20525 })
20526 .collect(),
20527 interactions: vec![gate4agent_types::ProviderInteraction {
20528 id: gate4agent_types::ProviderInteractionId(17),
20529 source: source.clone(),
20530 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20531 interaction_kind: ProviderInteractionKind::Approval,
20532 tool_name: " Approve\u{0007}Tool ".to_owned(),
20533 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20534 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20535 resume_lead_activity: Some(ProviderActivity::Working),
20536 status: ProviderInteractionStatus::Pending,
20537 }],
20538 subagents: vec![gate4agent_types::ProviderSubagent {
20539 source: source.clone(),
20540 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20541 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20542 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20543 }],
20544 sources: vec![gate4agent_types::ProviderSourceCursor {
20545 source,
20546 sequence: 17,
20547 gap_count: 2,
20548 stale: true,
20549 }],
20550 last_event: Some(ProviderEvent::Error {
20551 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20552 }),
20553 gap_count: 2,
20554 stale: true,
20555 };
20556 let address = terminal_address(3);
20557 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20558 assert_eq!(entry.address, address);
20559 assert_eq!(entry.progress.provider_sequence, 17);
20560 assert_eq!(entry.progress.active_tool_count, 9);
20561 assert_eq!(
20562 entry.progress.active_tool_labels.len(),
20563 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20564 );
20565 assert_eq!(entry.progress.subagent_count, 1);
20566 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20567 assert!(entry.progress.truncated);
20568 let encoded = serde_json::to_string(&entry).unwrap();
20569 for forbidden in [
20570 "PRIVATE_PROVIDER_SESSION",
20571 "PRIVATE_PROVIDER_PATH",
20572 "PRIVATE_MODEL",
20573 "PRIVATE_TOOL_INVENTORY",
20574 "PRIVATE_CURRENT_PROMPT",
20575 "PRIVATE_TOOL_INPUT_JSON",
20576 "PRIVATE_PROVIDER_REQUEST",
20577 "PRIVATE_INTERACTION_PROMPT",
20578 "PRIVATE_INTERACTION_AGENT",
20579 "PRIVATE_SUBAGENT_ID",
20580 "PRIVATE_SUBAGENT_TYPE",
20581 "PRIVATE_SUBAGENT_DESCRIPTION",
20582 "PRIVATE_PROVIDER_ERROR",
20583 "input_json",
20584 "current_prompt",
20585 "session_id",
20586 ] {
20587 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20588 }
20589
20590 let snapshot = NodeSnapshot {
20591 node_id: NodeId::new("node-progress-test").unwrap(),
20592 enabled_providers: vec![agent("claude")],
20593 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20594 workspaces: Vec::new(),
20595 session_records: Vec::new(),
20596 managed_worktrees: Vec::new(),
20597 launch_inventory: None,
20598 agent_progress: vec![entry],
20599 };
20600 let mut legacy_reply = ResponseEnvelope {
20601 request_id: 1,
20602 result: Ok(NodeResponse::Snapshot {
20603 event_sequence: 0,
20604 controller: None,
20605 snapshot: snapshot.clone(),
20606 }),
20607 };
20608 project_response_without_agent_progress(&mut legacy_reply);
20609 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20610 panic!("agent progress projection changed response kind");
20611 };
20612 assert!(legacy.agent_progress.is_empty());
20613 assert_eq!(snapshot.agent_progress.len(), 1);
20614 }
20615
20616 fn terminal_address(generation: u64) -> SessionAddress {
20617 SessionAddress {
20618 workspace_id: WorkspaceId::new("primary").unwrap(),
20619 session: SessionKey {
20620 instance_id: AgentInstanceId(41),
20621 generation: SessionGeneration(generation),
20622 },
20623 }
20624 }
20625
20626 fn spawn_spec_fixture() -> SpawnSpec {
20627 SpawnSpec {
20628 target: crate::protocol::SpawnTarget {
20629 node_id: NodeId::new("node-terminal-test").unwrap(),
20630 workspace_id: WorkspaceId::new("primary").unwrap(),
20631 worktree_id: None,
20632 },
20633 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20634 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20635 .unwrap(),
20636 overrides: crate::protocol::SpawnOverrides::default(),
20637 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20638 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20639 required_capabilities: SpawnRequiredCapabilities::default(),
20640 }
20641 }
20642
20643 fn environment_profile_test_shared(
20644 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20645 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20646 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20647 "local-claude-r1",
20648 )
20649 .unwrap();
20650 let spawn_profiles = SpawnProfileRegistry::new([
20651 crate::protocol::SpawnProfileDefaults {
20652 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20653 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20654 provider: agent("claude"),
20655 mode: SessionMode::Pty,
20656 terminal_size: gate4agent_types::TerminalSize {
20657 rows: 24,
20658 columns: 80,
20659 },
20660 prompt: None,
20661 bundle_id: None,
20662 context_id: None,
20663 environment_profile_id: Some(profile_id.clone()),
20664 },
20665 ])
20666 .unwrap();
20667 let catalog = active_registry().unwrap();
20668 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20669 let control = runtime.native_launch_profile_control();
20670 let node_profile = NodeEnvironmentProfile::new(
20671 profile_id.clone(),
20672 profile_revision.clone(),
20673 agent("claude"),
20674 [NativeLaunchProfile::new(
20675 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20676 agent("claude"),
20677 TransportKind::Pty,
20678 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20679 Arc::new(EmptyEnvironmentResolver),
20680 )
20681 .unwrap()],
20682 )
20683 .unwrap();
20684 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20685 for native_profile in native_profiles {
20686 runtime.upsert_native_launch_profile(native_profile).unwrap();
20687 }
20688 let workspace = WorkspaceConfig::new(
20689 WorkspaceId::new("primary").unwrap(),
20690 std::env::current_dir().unwrap(),
20691 )
20692 .unwrap();
20693 let shared = NodeShared::new_with_incarnation(
20694 handle,
20695 "fixture-token".to_owned(),
20696 NodeId::new("node-terminal-test").unwrap(),
20697 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20698 vec![workspace],
20699 vec![agent("claude")],
20700 ProviderRuntimeStatuses::default(),
20701 None,
20702 Vec::new(),
20703 Vec::new(),
20704 spawn_profiles,
20705 Some(control),
20706 None,
20707 None,
20708 None,
20709 Vec::new(),
20710 Vec::new(),
20711 Vec::new(),
20712 Vec::new(),
20713 None,
20714 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20715 );
20716 shared
20717 .environment_profiles
20718 .write()
20719 .unwrap_or_else(|poisoned| poisoned.into_inner())
20720 .insert(binding.id.clone(), binding);
20721 (
20722 shared,
20723 runtime,
20724 ResolvedEnvironmentProfileReceipt {
20725 profile_id,
20726 profile_revision,
20727 network_allowlist: None,
20728 browser_profile_id: None,
20729 },
20730 )
20731 }
20732
20733 fn materialization_test_shared(
20734 deny_secret: bool,
20735 ) -> (
20736 NodeShared,
20737 NativeRuntime,
20738 ResolvedEnvironmentProfileReceipt,
20739 PathBuf,
20740 Arc<AtomicBool>,
20741 ) {
20742 let workspace = WorkspaceConfig::new(
20743 WorkspaceId::new("primary").unwrap(),
20744 std::env::current_dir().unwrap(),
20745 )
20746 .unwrap();
20747 materialization_test_shared_with_workspace(deny_secret, workspace)
20748 }
20749
20750 fn materialization_test_shared_with_workspace(
20751 deny_secret: bool,
20752 workspace: WorkspaceConfig,
20753 ) -> (
20754 NodeShared,
20755 NativeRuntime,
20756 ResolvedEnvironmentProfileReceipt,
20757 PathBuf,
20758 Arc<AtomicBool>,
20759 ) {
20760 let root = temporary_workspace_root(if deny_secret {
20761 "materialization-denied"
20762 } else {
20763 "materialization-ready"
20764 });
20765 std::fs::create_dir_all(&root).unwrap();
20766 let materialization_root = root.join("session-environments");
20767 let deny = Arc::new(AtomicBool::new(deny_secret));
20768 let resolver = Arc::new(FixtureSecretResolver {
20769 deny: Arc::clone(&deny),
20770 });
20771 let materializer = SessionEnvironmentMaterializer::new(
20772 materialization_root,
20773 resolver,
20774 )
20775 .unwrap();
20776 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20777 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20778 "materialized-claude-r1",
20779 )
20780 .unwrap();
20781 let materialization = NodeSessionMaterializationProfile::new(
20782 vec![NodeSessionEnvironmentMutation::SetSecret {
20783 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20784 reference: NodeSecretReference::new("fixture-token").unwrap(),
20785 }],
20786 vec![NodeSessionPathBinding::new(
20787 "GATE4AGENT_PROVIDER_HOME",
20788 NodeSessionPathClass::ProviderHome,
20789 )
20790 .unwrap()],
20791 vec![NodeSessionFile::secret(
20792 NodeSessionPathClass::Config,
20793 "auth/token",
20794 NodeSecretReference::new("fixture-token").unwrap(),
20795 )
20796 .unwrap()],
20797 )
20798 .unwrap();
20799 let catalog = active_registry().unwrap();
20800 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20801 let control = runtime.native_launch_profile_control();
20802 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20803 profile_id.clone(),
20804 profile_revision.clone(),
20805 agent("claude"),
20806 [NativeLaunchProfile::new(
20807 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20808 agent("claude"),
20809 TransportKind::Pty,
20810 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20811 Arc::new(EmptyEnvironmentResolver),
20812 )
20813 .unwrap()],
20814 Some(materialization),
20815 )
20816 .unwrap();
20817 let (binding, native_profiles, materialization) = node_profile.into_parts();
20818 for native_profile in native_profiles {
20819 runtime.upsert_native_launch_profile(native_profile).unwrap();
20820 }
20821 let shared = NodeShared::new_with_incarnation(
20822 handle,
20823 "fixture-token".to_owned(),
20824 NodeId::new("node-materialization-test").unwrap(),
20825 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20826 vec![workspace],
20827 vec![agent("claude")],
20828 ProviderRuntimeStatuses::default(),
20829 None,
20830 Vec::new(),
20831 Vec::new(),
20832 SpawnProfileRegistry::default(),
20833 Some(control),
20834 None,
20835 Some(materializer),
20836 None,
20837 Vec::new(),
20838 Vec::new(),
20839 Vec::new(),
20840 Vec::new(),
20841 None,
20842 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20843 );
20844 shared
20845 .environment_profiles
20846 .write()
20847 .unwrap_or_else(|poisoned| poisoned.into_inner())
20848 .insert(binding.id.clone(), binding);
20849 shared
20850 .environment_materialization_profiles
20851 .write()
20852 .unwrap_or_else(|poisoned| poisoned.into_inner())
20853 .insert(profile_id.clone(), materialization.unwrap());
20854 (
20855 shared,
20856 runtime,
20857 ResolvedEnvironmentProfileReceipt {
20858 profile_id,
20859 profile_revision,
20860 network_allowlist: None,
20861 browser_profile_id: None,
20862 },
20863 root,
20864 deny,
20865 )
20866 }
20867
20868 #[cfg(feature = "fixture")]
20869 #[derive(Clone, Copy)]
20870 enum CodexHomeProfile {
20871 Missing,
20872 WrongClass,
20873 Exact,
20874 }
20875
20876 #[cfg(feature = "fixture")]
20877 fn codex_bundle_test_shared(
20878 home_profile: CodexHomeProfile,
20879 ) -> (
20880 NodeShared,
20881 NativeRuntime,
20882 ResolvedEnvironmentProfileReceipt,
20883 NodeBundle,
20884 PathBuf,
20885 ) {
20886 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20887 const CLAUDE_MANIFEST: &[u8] =
20888 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20889 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20890 const DIGEST: &str =
20891 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20892
20893 let (shared, mut runtime, _claude_receipt, root, _deny) =
20894 materialization_test_shared(false);
20895 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20896 CodexHomeProfile::Missing => "codex-home-missing",
20897 CodexHomeProfile::WrongClass => "codex-home-wrong",
20898 CodexHomeProfile::Exact => "codex-home-exact",
20899 })
20900 .unwrap();
20901 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20902 "codex-home-r1",
20903 )
20904 .unwrap();
20905 let materialization = match home_profile {
20906 CodexHomeProfile::Missing => None,
20907 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20908 Some(NodeSessionMaterializationProfile::new(
20909 Vec::new(),
20910 vec![NodeSessionPathBinding::new(
20911 "CODEX_HOME",
20912 if matches!(home_profile, CodexHomeProfile::Exact) {
20913 NodeSessionPathClass::ProviderHome
20914 } else {
20915 NodeSessionPathClass::Config
20916 },
20917 )
20918 .unwrap()],
20919 Vec::new(),
20920 )
20921 .unwrap())
20922 }
20923 };
20924 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20925 profile_id.clone(),
20926 profile_revision.clone(),
20927 agent("codex"),
20928 [NativeLaunchProfile::new(
20929 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20930 agent("codex"),
20931 TransportKind::Pty,
20932 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20933 Arc::new(EmptyEnvironmentResolver),
20934 )
20935 .unwrap()],
20936 materialization,
20937 )
20938 .unwrap();
20939 let (binding, native_profiles, materialization) = node_profile.into_parts();
20940 for native_profile in native_profiles {
20941 runtime.upsert_native_launch_profile(native_profile).unwrap();
20942 }
20943 shared
20944 .environment_profiles
20945 .write()
20946 .unwrap_or_else(|poisoned| poisoned.into_inner())
20947 .insert(binding.id.clone(), binding);
20948 if let Some(materialization) = materialization {
20949 shared
20950 .environment_materialization_profiles
20951 .write()
20952 .unwrap_or_else(|poisoned| poisoned.into_inner())
20953 .insert(profile_id.clone(), materialization);
20954 }
20955
20956 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20957 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20958 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20959 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20960 std::fs::write(
20961 bundle_source.join(".claude-plugin/plugin.json"),
20962 CLAUDE_MANIFEST,
20963 )
20964 .unwrap();
20965 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20966 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20967 let bundle = NodeBundle::new(
20968 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20969 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20970 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20971 &bundle_source,
20972 )
20973 .unwrap();
20974 *shared
20975 .bundle_catalog
20976 .write()
20977 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20978 BundleCatalog::new([bundle.clone()]).unwrap();
20979 (
20980 shared,
20981 runtime,
20982 ResolvedEnvironmentProfileReceipt {
20983 profile_id,
20984 profile_revision,
20985 network_allowlist: None,
20986 browser_profile_id: None,
20987 },
20988 bundle,
20989 root,
20990 )
20991 }
20992
20993 #[cfg(feature = "fixture")]
20994 #[test]
20995 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20996 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20997 let (shared, runtime, receipt, bundle, root) =
20998 codex_bundle_test_shared(home_profile);
20999 let error = shared
21000 .bundle_layout(
21001 &agent("codex"),
21002 SessionMode::Pty,
21003 Some(&receipt),
21004 &bundle,
21005 )
21006 .unwrap_err();
21007 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
21008 assert!(shared
21009 .materializations
21010 .lock()
21011 .unwrap_or_else(|poisoned| poisoned.into_inner())
21012 .is_empty());
21013 let materialization_root = root.join("session-environments");
21014 assert!(!std::fs::read_dir(&materialization_root)
21015 .unwrap()
21016 .filter_map(Result::ok)
21017 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
21018 drop(shared);
21019 drop(runtime);
21020 let _ = std::fs::remove_dir_all(root);
21021 }
21022 }
21023
21024 #[cfg(feature = "fixture")]
21025 #[test]
21026 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
21027 let (shared, runtime, receipt, bundle, root) =
21028 codex_bundle_test_shared(CodexHomeProfile::Exact);
21029 let address = SessionAddress {
21030 workspace_id: WorkspaceId::new("primary").unwrap(),
21031 session: SessionKey {
21032 instance_id: AgentInstanceId(310),
21033 generation: SessionGeneration(1),
21034 },
21035 };
21036 let (overlay, guard) = shared
21037 .prepare_session_materialization(
21038 &address,
21039 &agent("codex"),
21040 SessionMode::Pty,
21041 Some(&receipt),
21042 Some(&bundle.receipt()),
21043 None,
21044 None,
21045 )
21046 .unwrap()
21047 .unwrap();
21048 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
21049 let ownership = shared
21050 .materializations
21051 .lock()
21052 .unwrap_or_else(|poisoned| poisoned.into_inner())
21053 .get(guard.id().unwrap())
21054 .unwrap()
21055 .clone();
21056 let (profile, _, _) = shared
21057 .materialization_profile(
21058 &agent("codex"),
21059 SessionMode::Pty,
21060 Some(&receipt),
21061 Some(&bundle.receipt()),
21062 None,
21063 )
21064 .unwrap()
21065 .unwrap();
21066 let environment = shared
21067 .session_environment_materializer
21068 .as_ref()
21069 .unwrap()
21070 .resolve_environment(&ownership, &profile)
21071 .unwrap();
21072 assert!(environment.iter().any(|mutation| {
21073 mutation.key == OsString::from("CODEX_HOME")
21074 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21075 }));
21076 assert_eq!(
21077 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21078 .unwrap(),
21079 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21080 );
21081 assert!(std::fs::read_dir(ownership.bundle_root())
21082 .unwrap()
21083 .next()
21084 .is_none());
21085 drop(guard);
21086 drop(shared);
21087 drop(runtime);
21088 let _ = std::fs::remove_dir_all(root);
21089 }
21090
21091 #[cfg(feature = "fixture")]
21092 #[test]
21093 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21094 let (shared, runtime, receipt, bundle, root) =
21095 codex_bundle_test_shared(CodexHomeProfile::Exact);
21096 let bundle_receipt = bundle.receipt();
21097 let address = SessionAddress {
21098 workspace_id: WorkspaceId::new("primary").unwrap(),
21099 session: SessionKey {
21100 instance_id: AgentInstanceId(311),
21101 generation: SessionGeneration(1),
21102 },
21103 };
21104 let (_overlay, guard) = shared
21105 .prepare_session_materialization(
21106 &address,
21107 &agent("codex"),
21108 SessionMode::Pty,
21109 Some(&receipt),
21110 Some(&bundle_receipt),
21111 None,
21112 None,
21113 )
21114 .unwrap()
21115 .unwrap();
21116 let materialization_id = guard.id().unwrap().clone();
21117 let skill_path = shared
21118 .materializations
21119 .lock()
21120 .unwrap_or_else(|poisoned| poisoned.into_inner())
21121 .get(&materialization_id)
21122 .unwrap()
21123 .provider_home()
21124 .join("skills/review-code/SKILL.md");
21125 let record_id = shared
21126 .bind_spawn_session_with_materialization(
21127 &address,
21128 agent("codex"),
21129 SessionMode::Pty,
21130 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21131 SpawnRecordPolicy::ProviderIdentityOnly,
21132 Some(receipt.clone()),
21133 Some(bundle_receipt.clone()),
21134 None,
21135 Some(materialization_id.clone()),
21136 )
21137 .unwrap()
21138 .unwrap();
21139 guard.retain();
21140 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21141
21142 let error = match shared.resolve_record_materialization(
21143 &record_id,
21144 &agent("codex"),
21145 SessionMode::Pty,
21146 Some(&receipt),
21147 Some(&bundle_receipt),
21148 None,
21149 ) {
21150 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21151 Err(error) => error,
21152 };
21153 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21154 assert_eq!(
21155 shared
21156 .materializations
21157 .lock()
21158 .unwrap_or_else(|poisoned| poisoned.into_inner())
21159 .get(&materialization_id)
21160 .unwrap()
21161 .state(),
21162 MaterializationState::RecoveryRequired,
21163 );
21164 drop(shared);
21165 drop(runtime);
21166 let _ = std::fs::remove_dir_all(root);
21167 }
21168
21169 fn bind_record_owned_materialization_fixture(
21170 shared: &NodeShared,
21171 receipt: &ResolvedEnvironmentProfileReceipt,
21172 instance_id: u64,
21173 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21174 let address = SessionAddress {
21175 workspace_id: WorkspaceId::new("primary").unwrap(),
21176 session: SessionKey {
21177 instance_id: AgentInstanceId(instance_id),
21178 generation: SessionGeneration(1),
21179 },
21180 };
21181 let (overlay, materialization_guard) = shared
21182 .prepare_session_materialization(
21183 &address,
21184 &agent("claude"),
21185 SessionMode::Pty,
21186 Some(receipt),
21187 None,
21188 None,
21189 None,
21190 )
21191 .unwrap()
21192 .unwrap();
21193 let materialization_id = materialization_guard.id().unwrap().clone();
21194 let materialization_root = shared
21195 .materializations
21196 .lock()
21197 .unwrap_or_else(|poisoned| poisoned.into_inner())
21198 .get(&materialization_id)
21199 .unwrap()
21200 .root()
21201 .to_path_buf();
21202 let selection = shared
21203 .select_environment_profile(
21204 address.session.instance_id,
21205 &agent("claude"),
21206 SessionMode::Pty,
21207 Some(receipt),
21208 )
21209 .unwrap()
21210 .unwrap();
21211 assert!(shared
21212 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21213 .unwrap()
21214 .is_none());
21215 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21216 let record_id = shared
21217 .bind_spawn_session_with_materialization(
21218 &address,
21219 agent("claude"),
21220 SessionMode::Pty,
21221 policy,
21222 SpawnRecordPolicy::ProviderIdentityOnly,
21223 Some(receipt.clone()),
21224 None,
21225 None,
21226 Some(materialization_id.clone()),
21227 )
21228 .unwrap()
21229 .unwrap();
21230 selection.retain();
21231 materialization_guard.retain();
21232 (address, record_id, materialization_id, materialization_root)
21233 }
21234
21235 #[test]
21236 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21237 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21238 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21239 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21240 }));
21241 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21242 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21243 let mut spec = spawn_spec_fixture();
21244 spec.expected_profile_revision =
21245 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21246 assert!(matches!(
21247 &spec.overrides.environment_profile_id,
21248 crate::protocol::SpawnOverride::Inherit,
21249 ));
21250 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21251 .requires_child_environment_profile_capability());
21252 assert!(request_requires_child_environment_profile(
21253 &shared,
21254 &NodeRequest::SpawnSpec { spec: spec.clone() },
21255 ));
21256
21257 let address = terminal_address(1);
21258 shared.bind_session_with_policy(
21259 &address,
21260 ProviderRuntimePolicy::raw_pty(),
21261 Some(environment_profile.clone()),
21262 );
21263 assert!(request_requires_child_environment_profile(
21264 &shared,
21265 &NodeRequest::Input {
21266 session: address.clone(),
21267 text: "x".to_owned(),
21268 },
21269 ));
21270
21271 let mut record = record("claude", "profile-record");
21272 record.active_session = Some(address.clone());
21273 record.environment_profile = Some(environment_profile.clone());
21274 let record_id = record.record_id.clone();
21275 shared.insert_record(record.clone()).unwrap();
21276 assert!(request_requires_child_environment_profile(
21277 &shared,
21278 &NodeRequest::ResumeSessionRecord {
21279 record_id,
21280 terminal_size: gate4agent_types::TerminalSize {
21281 rows: 24,
21282 columns: 80,
21283 },
21284 initial_prompt: None,
21285 },
21286 ));
21287
21288 let snapshot = snapshot_for_wire(
21289 &shared, false, true, true, true, false, false, false, false, true,
21290 );
21291 assert_eq!(snapshot.session_records.len(), 1);
21292 assert!(snapshot.session_records[0].environment_profile.is_none());
21293 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21294 sequence: 1,
21295 event: NodeEvent::SessionRecordUpserted { record },
21296 });
21297 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21298 panic!("record projection changed the event kind");
21299 };
21300 assert!(record.environment_profile.is_none());
21301
21302 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21303 let mut reply = ResponseEnvelope {
21304 request_id: 1,
21305 result: Ok(NodeResponse::SpawnSpecAccepted {
21306 receipt: resolved.receipt_with_environment(
21307 shared.incarnation_id,
21308 address,
21309 Some(environment_profile),
21310 ),
21311 }),
21312 };
21313 project_response_without_child_environment_profile(&mut reply);
21314 assert_eq!(
21315 reply.result.unwrap_err().code,
21316 NodeFailureCode::UnsupportedCapability,
21317 );
21318 }
21319
21320 #[test]
21321 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21322 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21323 let snapshot = shared.snapshot();
21324 let profiles = snapshot
21325 .launch_inventory
21326 .as_ref()
21327 .and_then(|inventory| inventory.spawn_profiles.as_ref())
21328 .unwrap();
21329 assert_eq!(profiles.len(), 1);
21330 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21331 assert!(snapshot.requires_child_environment_profile_capability());
21332
21333 let mut legacy = snapshot.clone();
21334 clear_snapshot_child_environment_profiles(&mut legacy);
21335 assert!(legacy
21336 .launch_inventory
21337 .as_ref()
21338 .unwrap()
21339 .spawn_profiles
21340 .as_ref()
21341 .unwrap()[0]
21342 .environment_profile
21343 .is_none());
21344 assert!(!legacy.requires_child_environment_profile_capability());
21345
21346 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21347 let bindings = shared
21348 .environment_profiles
21349 .read()
21350 .unwrap_or_else(|poisoned| poisoned.into_inner())
21351 .clone();
21352 let mut provider_mismatch = bindings.clone();
21353 provider_mismatch
21354 .values_mut()
21355 .next()
21356 .unwrap()
21357 .provider = agent("codex");
21358 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21359 let mut mode_mismatch = profile;
21360 mode_mismatch.mode = SessionMode::Inline;
21361 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21362
21363 shared
21364 .environment_profiles
21365 .write()
21366 .unwrap_or_else(|poisoned| poisoned.into_inner())
21367 .clear();
21368 assert!(shared
21369 .snapshot()
21370 .launch_inventory
21371 .unwrap()
21372 .spawn_profiles
21373 .unwrap()
21374 .is_empty());
21375 }
21376
21377 #[test]
21378 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21379 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21380 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21381 }));
21382 let shared = terminal_test_shared();
21383 let bundle = ResolvedBundleReceipt {
21384 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21385 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21386 digest: crate::protocol::SpawnBundleDigest::new(format!(
21387 "sha256:{}",
21388 "0".repeat(64),
21389 ))
21390 .unwrap(),
21391 };
21392 let mut bundled = record("claude", "bundle-record");
21393 bundled.bundle = Some(bundle.clone());
21394 let record_id = bundled.record_id.clone();
21395 shared.insert_record(bundled.clone()).unwrap();
21396 assert!(request_requires_session_bundle(
21397 &shared,
21398 &NodeRequest::ForgetSessionRecord {
21399 record_id: record_id.clone(),
21400 },
21401 ));
21402
21403 let snapshot = snapshot_for_wire(
21404 &shared, false, true, true, true, true, false, false, false, true,
21405 );
21406 assert!(snapshot.session_records[0].bundle.is_none());
21407 let launch = snapshot.launch_inventory.as_ref().unwrap();
21408 assert!(launch.spawn_profiles.is_some());
21409 assert!(launch.bundles.is_none());
21410 let event = project_event_without_session_bundle(NodeEventEnvelope {
21411 sequence: 1,
21412 event: NodeEvent::SessionRecordUpserted { record: bundled },
21413 });
21414 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21415 panic!("record projection changed the event kind");
21416 };
21417 assert!(record.bundle.is_none());
21418
21419 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21420 let mut reply = ResponseEnvelope {
21421 request_id: 1,
21422 result: Ok(NodeResponse::SpawnSpecAccepted {
21423 receipt: resolved.receipt_with_materialization(
21424 shared.incarnation_id,
21425 terminal_address(17),
21426 None,
21427 Some(bundle),
21428 None,
21429 ),
21430 }),
21431 };
21432 project_response_without_session_bundle(&mut reply);
21433 assert_eq!(
21434 reply.result.unwrap_err().code,
21435 NodeFailureCode::UnsupportedCapability,
21436 );
21437 }
21438
21439 #[test]
21440 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21441 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21442 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21443 }));
21444 let context_id = SpawnContextId::new("context-review").unwrap();
21445 let context = ResolvedContextPackReceipt {
21446 id: context_id.clone(),
21447 digest: crate::protocol::SpawnContextDigest::new(format!(
21448 "sha256:{}",
21449 "c".repeat(64),
21450 ))
21451 .unwrap(),
21452 lineage: ContextPackLineageReceipt {
21453 source_node_id: NodeId::new("node-source").unwrap(),
21454 source_session: terminal_address(1),
21455 source_provider: agent("kimi"),
21456 },
21457 source_message_count: 2,
21458 retained_message_count: 2,
21459 byte_len: 256,
21460 truncated: false,
21461 };
21462 assert!(context.is_valid());
21463 let mut bound_record = record("codex", "context-record");
21464 bound_record.context_id = Some(context_id);
21465 bound_record.context = Some(context.clone());
21466 let session = gate4agent_types::SessionSnapshot {
21467 instance_id: AgentInstanceId(91),
21468 agent_id: agent("kimi"),
21469 transport: TransportKind::Pty,
21470 generation: SessionGeneration(3),
21471 status: SessionStatus::Running,
21472 pending_operation: None,
21473 pending_input: None,
21474 process_id: Some(9001),
21475 terminal_size: None,
21476 terminal_frame: None,
21477 terminal_stale: None,
21478 session_options: None,
21479 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21480 history: gate4agent_types::HistorySnapshot {
21481 pending: None,
21482 candidates: vec![HistoryCandidateSummary {
21483 id: "candidate-opaque".to_owned(),
21484 session_id_hint: "session-hint".to_owned(),
21485 modified_at_unix_ms: Some(1),
21486 }],
21487 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21488 loaded: Some(HistorySessionRecord {
21489 session_id: "private-vendor-session".to_owned(),
21490 title: Some("private title".to_owned()),
21491 cwd: Some(r"C:\private\history-root".to_owned()),
21492 model: Some("private-model".to_owned()),
21493 message_count: 2,
21494 completed_turn_count: None,
21495 total_tokens: 19,
21496 messages: vec![
21497 gate4agent_types::HistoryMessageRecord {
21498 role: gate4agent_types::HistoryMessageRole::User,
21499 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21500 },
21501 gate4agent_types::HistoryMessageRecord {
21502 role: gate4agent_types::HistoryMessageRole::Assistant,
21503 text: "private answer".to_owned(),
21504 },
21505 ],
21506 }),
21507 last_error: None,
21508 },
21509 resume: gate4agent_types::ResumeSnapshot::default(),
21510 foreground: gate4agent_types::ForegroundSnapshot::default(),
21511 provider: gate4agent_types::ProviderSnapshot::default(),
21512 screen_state: gate4agent_types::PtyScreenState::default(),
21513 };
21514 let original = NodeSnapshot {
21515 node_id: NodeId::new("node-terminal-test").unwrap(),
21516 enabled_providers: vec![agent("kimi"), agent("codex")],
21517 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21518 workspaces: vec![WorkspaceSnapshot {
21519 workspace_id: WorkspaceId::new("primary").unwrap(),
21520 canonical_root: opaque_windows_path(
21521 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21522 ),
21523 sessions: vec![session],
21524 worktree_service_mode: None,
21525 managed_worktree_profiles: None,
21526 }],
21527 session_records: vec![bound_record],
21528 managed_worktrees: Vec::new(),
21529 launch_inventory: None,
21530 agent_progress: Vec::new(),
21531 };
21532
21533 let mut negotiated = original.clone();
21534 project_snapshot_history_for_wire(&mut negotiated, true);
21535 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21536 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21537 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21538 assert!(!serde_json::to_string(&negotiated)
21539 .unwrap()
21540 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21541
21542 let mut legacy = original;
21543 project_snapshot_history_for_wire(&mut legacy, false);
21544 clear_snapshot_context_packs(&mut legacy);
21545 assert_eq!(
21546 legacy.workspaces[0].sessions[0].history,
21547 gate4agent_types::HistorySnapshot::default(),
21548 );
21549 assert!(legacy.session_records[0].context_id.is_none());
21550 assert!(legacy.session_records[0].context.is_none());
21551 let encoded = serde_json::to_string(&legacy).unwrap();
21552 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21553 assert!(!encoded.contains("context-review"));
21554
21555 let mut reply = ResponseEnvelope {
21556 request_id: 7,
21557 result: Ok(NodeResponse::ContextPackExported { context }),
21558 };
21559 project_response_without_context_pack(&mut reply);
21560 assert_eq!(
21561 reply.result.unwrap_err().code,
21562 NodeFailureCode::UnsupportedCapability,
21563 );
21564 }
21565
21566 #[test]
21567 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21568 let shared = terminal_test_shared();
21569 let history = HistorySessionRecord {
21570 session_id: "fixture-history".to_owned(),
21571 title: None,
21572 cwd: None,
21573 model: None,
21574 message_count: 1,
21575 completed_turn_count: None,
21576 total_tokens: 0,
21577 messages: vec![gate4agent_types::HistoryMessageRecord {
21578 role: gate4agent_types::HistoryMessageRole::User,
21579 text: "bounded handoff".to_owned(),
21580 }],
21581 };
21582 let pack = NodeContextPack::export(
21583 ContextPackLineageReceipt {
21584 source_node_id: shared.node_id.clone(),
21585 source_session: terminal_address(1),
21586 source_provider: agent("claude"),
21587 },
21588 &history,
21589 )
21590 .unwrap();
21591 let context = shared
21592 .context_catalog
21593 .write()
21594 .unwrap_or_else(|poisoned| poisoned.into_inner())
21595 .insert(pack)
21596 .unwrap();
21597 let mut retained = record("codex", "context-retained-record");
21598 retained.context_id = Some(context.id.clone());
21599 retained.context = Some(context.clone());
21600 let record_id = retained.record_id.clone();
21601 shared.insert_record(retained).unwrap();
21602 assert_eq!(
21603 shared.forget_context_pack(&context.id).unwrap_err().code,
21604 NodeFailureCode::ContextPackBusy,
21605 );
21606 assert!(shared
21607 .context_catalog
21608 .read()
21609 .unwrap_or_else(|poisoned| poisoned.into_inner())
21610 .get(&context.id)
21611 .is_some());
21612 shared
21613 .session_records
21614 .lock()
21615 .unwrap_or_else(|poisoned| poisoned.into_inner())
21616 .records
21617 .remove(&record_id);
21618
21619 let mut spec = spawn_spec_fixture();
21620 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21621 value: context.id.clone(),
21622 };
21623 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21624 let receipt = resolved.receipt_with_materialization(
21625 shared.incarnation_id,
21626 terminal_address(2),
21627 None,
21628 None,
21629 Some(context.clone()),
21630 );
21631 assert!(receipt.context_binding_is_valid());
21632 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21633
21634 shared.forget_context_pack(&context.id).unwrap();
21635 assert!(shared
21636 .context_catalog
21637 .read()
21638 .unwrap_or_else(|poisoned| poisoned.into_inner())
21639 .get(&context.id)
21640 .is_none());
21641 assert!(shared
21642 .replay_spawn_spec(&spec, Instant::now())
21643 .unwrap()
21644 .is_none());
21645 }
21646
21647 #[test]
21648 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21649 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21650 let address = terminal_address(1);
21651 let selection = shared
21652 .select_environment_profile(
21653 address.session.instance_id,
21654 &agent("claude"),
21655 SessionMode::Pty,
21656 Some(&environment_profile),
21657 )
21658 .unwrap()
21659 .unwrap();
21660 shared.bind_session_with_policy(
21661 &address,
21662 ProviderRuntimePolicy::raw_pty(),
21663 Some(environment_profile.clone()),
21664 );
21665 selection.retain();
21666 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21667 assert!(matches!(
21668 runtime.remove_native_launch_profile(&native_id),
21669 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21670 ));
21671 assert!(shared.remove_binding(&address).is_some());
21672 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21673
21674 let mismatched = ResolvedEnvironmentProfileReceipt {
21675 profile_id: environment_profile.profile_id,
21676 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21677 "local-claude-r2",
21678 )
21679 .unwrap(),
21680 network_allowlist: None,
21681 browser_profile_id: None,
21682 };
21683 let error = match shared.select_environment_profile(
21684 AgentInstanceId(42),
21685 &agent("claude"),
21686 SessionMode::Pty,
21687 Some(&mismatched),
21688 ) {
21689 Ok(_) => panic!("mismatched environment profile revision was selected"),
21690 Err(error) => error,
21691 };
21692 assert_eq!(
21693 error.code,
21694 NodeFailureCode::EnvironmentProfileBindingMismatch,
21695 );
21696 assert!(!shared
21697 .session_bindings
21698 .lock()
21699 .unwrap_or_else(|poisoned| poisoned.into_inner())
21700 .contains_key(&AgentInstanceId(42)));
21701 }
21702
21703 #[test]
21704 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21705 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21706 let address = terminal_address(1);
21707 let selection = shared
21708 .select_environment_profile(
21709 address.session.instance_id,
21710 &agent("claude"),
21711 SessionMode::Pty,
21712 Some(&environment_profile),
21713 )
21714 .unwrap()
21715 .unwrap();
21716 let overlay = NativeInstanceLaunchOverlay::new(
21717 agent("claude"),
21718 TransportKind::Pty,
21719 vec![EnvMutation {
21720 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21721 value: Some(OsString::from("fixture-secret")),
21722 }],
21723 vec![
21724 OsString::from("--plugin-dir"),
21725 std::env::current_dir().unwrap().into_os_string(),
21726 ],
21727 )
21728 .unwrap();
21729 let instance_overlay = shared
21730 .install_prepared_launch_overlay(
21731 address.session.instance_id,
21732 PreparedNativeLaunchOverlay::Instance(overlay),
21733 )
21734 .unwrap()
21735 .unwrap();
21736 let bundle = ResolvedBundleReceipt {
21737 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21738 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21739 digest: crate::protocol::SpawnBundleDigest::new(format!(
21740 "sha256:{}",
21741 "0".repeat(64),
21742 ))
21743 .unwrap(),
21744 };
21745 shared.bind_session_with_materialization(
21746 &address,
21747 ProviderRuntimePolicy::raw_pty(),
21748 Some(environment_profile),
21749 Some(bundle),
21750 None,
21751 None,
21752 );
21753 selection.retain();
21754 instance_overlay.retain();
21755
21756 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21757 assert!(matches!(
21758 runtime.remove_native_launch_profile(&native_id),
21759 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21760 ));
21761 assert!(shared.remove_binding(&address).is_some());
21762 assert!(!shared
21763 .native_launch_profile_control
21764 .as_ref()
21765 .unwrap()
21766 .clear_native_instance_launch_overlay(address.session.instance_id));
21767 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21768 }
21769
21770 #[test]
21771 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21772 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21773 let address = SessionAddress {
21774 workspace_id: WorkspaceId::new("primary").unwrap(),
21775 session: SessionKey {
21776 instance_id: AgentInstanceId(201),
21777 generation: SessionGeneration(1),
21778 },
21779 };
21780 let error = match shared.prepare_session_materialization(
21781 &address,
21782 &agent("claude"),
21783 SessionMode::Pty,
21784 Some(&receipt),
21785 None,
21786 None,
21787 None,
21788 ) {
21789 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21790 Err(error) => error,
21791 };
21792 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21793 assert!(shared.handle.snapshot().sessions.is_empty());
21794 assert!(shared
21795 .session_bindings
21796 .lock()
21797 .unwrap_or_else(|poisoned| poisoned.into_inner())
21798 .is_empty());
21799 assert!(shared
21800 .materializations
21801 .lock()
21802 .unwrap_or_else(|poisoned| poisoned.into_inner())
21803 .is_empty());
21804 drop(shared);
21805 drop(runtime);
21806 std::fs::remove_dir_all(root).unwrap();
21807 }
21808
21809 #[test]
21810 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21811 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21812 let address = SessionAddress {
21813 workspace_id: WorkspaceId::new("primary").unwrap(),
21814 session: SessionKey {
21815 instance_id: AgentInstanceId(202),
21816 generation: SessionGeneration(1),
21817 },
21818 };
21819 let (overlay, materialization_guard) = shared
21820 .prepare_session_materialization(
21821 &address,
21822 &agent("claude"),
21823 SessionMode::Pty,
21824 Some(&receipt),
21825 None,
21826 None,
21827 None,
21828 )
21829 .unwrap()
21830 .unwrap();
21831 let materialization_id = materialization_guard.id().unwrap().clone();
21832 let materialization_root = shared
21833 .materializations
21834 .lock()
21835 .unwrap_or_else(|poisoned| poisoned.into_inner())
21836 .get(&materialization_id)
21837 .unwrap()
21838 .root()
21839 .to_path_buf();
21840 let selection = shared
21841 .select_environment_profile(
21842 address.session.instance_id,
21843 &agent("claude"),
21844 SessionMode::Pty,
21845 Some(&receipt),
21846 )
21847 .unwrap()
21848 .unwrap();
21849 assert!(shared
21850 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21851 .unwrap()
21852 .is_none());
21853 shared.bind_session_with_materialization(
21854 &address,
21855 ProviderRuntimePolicy::raw_pty(),
21856 Some(receipt),
21857 None,
21858 None,
21859 Some(materialization_id),
21860 );
21861 selection.retain();
21862 materialization_guard.retain();
21863
21864 assert!(materialization_root.is_dir());
21865 assert!(shared
21866 .session_bindings
21867 .lock()
21868 .unwrap_or_else(|poisoned| poisoned.into_inner())
21869 .contains_key(&address.session.instance_id));
21870
21871 let removed = shared.remove_binding(&address).unwrap();
21872 shared
21873 .cleanup_session_owned_materialization(&address, &removed)
21874 .unwrap();
21875 assert!(!materialization_root.exists());
21876 assert!(shared
21877 .materializations
21878 .lock()
21879 .unwrap_or_else(|poisoned| poisoned.into_inner())
21880 .is_empty());
21881 drop(shared);
21882 drop(runtime);
21883 std::fs::remove_dir_all(root).unwrap();
21884 }
21885
21886 #[tokio::test]
21887 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21888 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21889 let address = SessionAddress {
21890 workspace_id: WorkspaceId::new("primary").unwrap(),
21891 session: SessionKey {
21892 instance_id: AgentInstanceId(203),
21893 generation: SessionGeneration(1),
21894 },
21895 };
21896 let (overlay, materialization_guard) = shared
21897 .prepare_session_materialization(
21898 &address,
21899 &agent("claude"),
21900 SessionMode::Pty,
21901 Some(&receipt),
21902 None,
21903 None,
21904 None,
21905 )
21906 .unwrap()
21907 .unwrap();
21908 let materialization_id = materialization_guard.id().unwrap().clone();
21909 let materialization_root = shared
21910 .materializations
21911 .lock()
21912 .unwrap_or_else(|poisoned| poisoned.into_inner())
21913 .get(&materialization_id)
21914 .unwrap()
21915 .root()
21916 .to_path_buf();
21917 let selection = shared
21918 .select_environment_profile(
21919 address.session.instance_id,
21920 &agent("claude"),
21921 SessionMode::Pty,
21922 Some(&receipt),
21923 )
21924 .unwrap()
21925 .unwrap();
21926 assert!(shared
21927 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21928 .unwrap()
21929 .is_none());
21930 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21931 let record_id = shared
21932 .bind_spawn_session_with_materialization(
21933 &address,
21934 agent("claude"),
21935 SessionMode::Pty,
21936 policy,
21937 SpawnRecordPolicy::ProviderIdentityOnly,
21938 Some(receipt.clone()),
21939 None,
21940 None,
21941 Some(materialization_id.clone()),
21942 )
21943 .unwrap()
21944 .unwrap();
21945 selection.retain();
21946 materialization_guard.retain();
21947 assert_eq!(
21948 shared
21949 .materializations
21950 .lock()
21951 .unwrap_or_else(|poisoned| poisoned.into_inner())
21952 .get(&materialization_id)
21953 .unwrap()
21954 .owner(),
21955 &MaterializationOwner::Record {
21956 record_id: record_id.clone(),
21957 },
21958 );
21959
21960 let removed = shared.remove_binding(&address).unwrap();
21961 shared
21962 .cleanup_session_owned_materialization(&address, &removed)
21963 .unwrap();
21964 assert!(materialization_root.is_dir());
21965 assert!(shared
21966 .resolve_record_materialization(
21967 &record_id,
21968 &agent("claude"),
21969 SessionMode::Pty,
21970 Some(&receipt),
21971 None,
21972 None,
21973 )
21974 .unwrap()
21975 .is_some());
21976 {
21977 let mut records = shared
21978 .session_records
21979 .lock()
21980 .unwrap_or_else(|poisoned| poisoned.into_inner());
21981 let record = records.records.get_mut(&record_id).unwrap();
21982 record.active_session = None;
21983 record.state = ManagedSessionState::Unavailable;
21984 }
21985 shared.forget_session_record(&record_id).await.unwrap();
21986 assert!(!materialization_root.exists());
21987 assert!(shared
21988 .materializations
21989 .lock()
21990 .unwrap_or_else(|poisoned| poisoned.into_inner())
21991 .is_empty());
21992 drop(shared);
21993 drop(runtime);
21994 std::fs::remove_dir_all(root).unwrap();
21995 }
21996
21997 #[test]
21998 fn record_materialization_revalidation_failure_persists_recovery_required() {
21999 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22000 let state_path = root.join("node-state-v6.json");
22001 shared.state_path = Some(state_path.clone());
22002 let (_address, _record_id, materialization_id, materialization_root) =
22003 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
22004 std::fs::write(
22005 materialization_root.join(".gate4agent-materialization-owner"),
22006 b"tampered",
22007 )
22008 .unwrap();
22009
22010 let error = match shared.resolve_record_materialization(
22011 &_record_id,
22012 &agent("claude"),
22013 SessionMode::Pty,
22014 Some(&receipt),
22015 None,
22016 None,
22017 ) {
22018 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
22019 Err(error) => error,
22020 };
22021 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22022 assert_eq!(
22023 shared
22024 .materializations
22025 .lock()
22026 .unwrap_or_else(|poisoned| poisoned.into_inner())
22027 .get(&materialization_id)
22028 .unwrap()
22029 .state(),
22030 MaterializationState::RecoveryRequired,
22031 );
22032 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
22033 assert_eq!(loaded.materializations.len(), 1);
22034 assert_eq!(
22035 loaded.materializations[0].state(),
22036 MaterializationState::RecoveryRequired,
22037 );
22038 drop(shared);
22039 drop(runtime);
22040 std::fs::remove_dir_all(root).unwrap();
22041 }
22042
22043 #[test]
22044 fn transient_record_materialization_resolution_failure_remains_ready() {
22045 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
22046 let (_address, record_id, materialization_id, _materialization_root) =
22047 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
22048 deny.store(true, Ordering::Release);
22049
22050 let error = match shared.resolve_record_materialization(
22051 &record_id,
22052 &agent("claude"),
22053 SessionMode::Pty,
22054 Some(&receipt),
22055 None,
22056 None,
22057 ) {
22058 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
22059 Err(error) => error,
22060 };
22061 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22062 assert_eq!(
22063 shared
22064 .materializations
22065 .lock()
22066 .unwrap_or_else(|poisoned| poisoned.into_inner())
22067 .get(&materialization_id)
22068 .unwrap()
22069 .state(),
22070 MaterializationState::Ready,
22071 );
22072 drop(shared);
22073 drop(runtime);
22074 std::fs::remove_dir_all(root).unwrap();
22075 }
22076
22077 #[test]
22078 fn startup_missing_record_materialization_is_not_resumable() {
22079 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22080 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22081 shared
22082 .insert_record(ManagedSessionRecord {
22083 record_id: record_id.clone(),
22084 display_name: "missing materialization".to_owned(),
22085 provider: agent("claude"),
22086 mode: SessionMode::Pty,
22087 state: ManagedSessionState::Dormant,
22088 workspace_id: WorkspaceId::new("primary").unwrap(),
22089 canonical_root: opaque_windows_path(
22090 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22091 ),
22092 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22093 key: gate4agent_types::ProviderSessionKey::SessionId,
22094 id: "provider-session-missing-materialization".to_owned(),
22095 transcript_path: None,
22096 }),
22097 active_session: None,
22098 environment_profile: Some(receipt),
22099 bundle: None,
22100 context_id: None,
22101 context: None,
22102 exported_context: None,
22103 task_binding: None,
22104 created_at_unix_ms: 1,
22105 updated_at_unix_ms: 1,
22106 last_error: None,
22107 })
22108 .unwrap();
22109
22110 shared.reconcile_materializations();
22111 let record = shared.record(&record_id).unwrap();
22112 assert_eq!(record.state, ManagedSessionState::Unavailable);
22113 assert!(record.provider_session.is_none());
22114 assert_eq!(
22115 record.last_error.as_deref(),
22116 Some("environment-profile-unavailable"),
22117 );
22118 drop(shared);
22119 drop(runtime);
22120 std::fs::remove_dir_all(root).unwrap();
22121 }
22122
22123 #[tokio::test]
22124 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22125 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22126 let lease = install_managed_lease(&shared);
22127 let address = SessionAddress {
22128 workspace_id: WorkspaceId::new("primary").unwrap(),
22129 session: SessionKey {
22130 instance_id: AgentInstanceId(204),
22131 generation: SessionGeneration(1),
22132 },
22133 };
22134 let (overlay, materialization_guard) = shared
22135 .prepare_session_materialization(
22136 &address,
22137 &agent("claude"),
22138 SessionMode::Pty,
22139 Some(&receipt),
22140 None,
22141 None,
22142 Some(lease.lease_id.clone()),
22143 )
22144 .unwrap()
22145 .unwrap();
22146 let materialization_id = materialization_guard.id().unwrap().clone();
22147 let materialization_root = shared
22148 .materializations
22149 .lock()
22150 .unwrap_or_else(|poisoned| poisoned.into_inner())
22151 .get(&materialization_id)
22152 .unwrap()
22153 .root()
22154 .to_path_buf();
22155 let selection = shared
22156 .select_environment_profile(
22157 address.session.instance_id,
22158 &agent("claude"),
22159 SessionMode::Pty,
22160 Some(&receipt),
22161 )
22162 .unwrap()
22163 .unwrap();
22164 assert!(shared
22165 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22166 .unwrap()
22167 .is_none());
22168 shared.bind_session_with_materialization(
22169 &address,
22170 ProviderRuntimePolicy::raw_pty(),
22171 Some(receipt),
22172 None,
22173 None,
22174 Some(materialization_id),
22175 );
22176 selection.retain();
22177 materialization_guard.retain();
22178
22179 let blocked = shared
22180 .cleanup_managed_worktree(&lease.lease_id, false)
22181 .await
22182 .unwrap_err();
22183 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22184 assert!(materialization_root.is_dir());
22185 let removed = shared.remove_binding(&address).unwrap();
22186 shared
22187 .cleanup_session_owned_materialization(&address, &removed)
22188 .unwrap();
22189 assert!(!materialization_root.exists());
22190 drop(shared);
22191 drop(runtime);
22192 std::fs::remove_dir_all(root).unwrap();
22193 }
22194
22195 #[tokio::test]
22196 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22197 let git_root = temporary_workspace_root("raw-managed-inventory");
22198 let source = git_root.join("source");
22199 let allocation = git_root.join("allocation");
22200 std::fs::create_dir_all(&source).unwrap();
22201 std::fs::create_dir_all(&allocation).unwrap();
22202 run_test_git(&source, &["init"]);
22203 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22204 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22205 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22206 run_test_git(&source, &["add", "seed.txt"]);
22207 run_test_git(&source, &["commit", "-m", "seed"]);
22208 let profile = ManagedWorktreeProfile::new(
22209 WorktreeProfileId::new("default").unwrap(),
22210 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22211 &allocation,
22212 "gate4agent",
22213 "HEAD",
22214 ManagedWorktreeRetention::RemoveWhenReleased,
22215 )
22216 .unwrap();
22217 let workspace = WorkspaceConfig::new(
22218 WorkspaceId::new("primary").unwrap(),
22219 &source,
22220 )
22221 .unwrap()
22222 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22223 .with_managed_worktree_profile(profile.clone())
22224 .unwrap();
22225 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22226 materialization_test_shared_with_workspace(false, workspace);
22227 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22228 let source_text = source.to_string_lossy().into_owned();
22229 let target_text = target.to_string_lossy().into_owned();
22230 let created = create_git_worktree(
22231 &source_text,
22232 &target_text,
22233 "gate4agent/mw-raw-inventory",
22234 Some("HEAD"),
22235 )
22236 .await
22237 .unwrap();
22238 let lease = ManagedWorktreeLeaseRecord {
22239 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22240 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22241 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22242 profile_id: profile.profile_id().clone(),
22243 profile_revision: profile.revision().clone(),
22244 target_root: created.path.clone(),
22245 branch: "gate4agent/mw-raw-inventory".to_owned(),
22246 base_commit: created.head.clone(),
22247 expected_head: Some(created.head.clone()),
22248 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22249 state: ManagedWorktreeLeaseState::Ready,
22250 session_holders: Vec::new(),
22251 record_holders: Vec::new(),
22252 cleanup_failure: None,
22253 created_at_unix_ms: 1,
22254 updated_at_unix_ms: 1,
22255 };
22256 shared
22257 .workspaces
22258 .write()
22259 .unwrap_or_else(|poisoned| poisoned.into_inner())
22260 .insert(lease.workspace_id.clone(), created.path.clone());
22261 *shared
22262 .managed_worktrees
22263 .lock()
22264 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22265 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22266 let address = SessionAddress {
22267 workspace_id: lease.workspace_id.clone(),
22268 session: SessionKey {
22269 instance_id: AgentInstanceId(205),
22270 generation: SessionGeneration(1),
22271 },
22272 };
22273 let (overlay, materialization_guard) = shared
22274 .prepare_session_materialization(
22275 &address,
22276 &agent("claude"),
22277 SessionMode::Pty,
22278 Some(&receipt),
22279 None,
22280 None,
22281 Some(lease.lease_id.clone()),
22282 )
22283 .unwrap()
22284 .unwrap();
22285 let materialization_id = materialization_guard.id().unwrap().clone();
22286 let materialization_root = shared
22287 .materializations
22288 .lock()
22289 .unwrap_or_else(|poisoned| poisoned.into_inner())
22290 .get(&materialization_id)
22291 .unwrap()
22292 .root()
22293 .to_path_buf();
22294 let selection = shared
22295 .select_environment_profile(
22296 address.session.instance_id,
22297 &agent("claude"),
22298 SessionMode::Pty,
22299 Some(&receipt),
22300 )
22301 .unwrap()
22302 .unwrap();
22303 assert!(shared
22304 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22305 .unwrap()
22306 .is_none());
22307 let record_id = shared
22308 .bind_spawn_session_with_materialization(
22309 &address,
22310 agent("claude"),
22311 SessionMode::Pty,
22312 ProviderRuntimePolicy::raw_pty(),
22313 SpawnRecordPolicy::Always,
22314 Some(receipt.clone()),
22315 None,
22316 None,
22317 Some(materialization_id.clone()),
22318 )
22319 .unwrap()
22320 .unwrap();
22321 selection.retain();
22322 materialization_guard.retain();
22323 let record = shared.record(&record_id).unwrap();
22324 assert_eq!(record.state, ManagedSessionState::Live);
22325 assert!(record.provider_session.is_none());
22326 assert!(record.environment_profile.is_none());
22327 assert!(record.bundle.is_none());
22328 assert!(record.context_id.is_none());
22329 assert!(record.context.is_none());
22330 assert_eq!(record.active_session.as_ref(), Some(&address));
22331 assert_eq!(
22332 shared
22333 .materializations
22334 .lock()
22335 .unwrap_or_else(|poisoned| poisoned.into_inner())
22336 .get(&materialization_id)
22337 .unwrap()
22338 .owner(),
22339 &MaterializationOwner::Session {
22340 incarnation_id: shared.incarnation_id,
22341 instance_id: address.session.instance_id,
22342 generation: address.session.generation,
22343 },
22344 );
22345 {
22346 let mut bindings = shared
22347 .session_bindings
22348 .lock()
22349 .unwrap_or_else(|poisoned| poisoned.into_inner());
22350 bindings
22351 .get_mut(&address.session.instance_id)
22352 .unwrap()
22353 .managed_worktree_lease_id = Some(lease.lease_id.clone());
22354 }
22355 let lease_snapshot = shared
22356 .managed_worktrees
22357 .lock()
22358 .unwrap_or_else(|poisoned| poisoned.into_inner())
22359 .bind_session(
22360 &lease.lease_id,
22361 ManagedWorktreeSessionHolder {
22362 incarnation_id: shared.incarnation_id,
22363 instance_id: address.session.instance_id,
22364 generation: address.session.generation,
22365 },
22366 None,
22367 unix_time_ms(),
22368 )
22369 .unwrap();
22370 assert_eq!(lease_snapshot.active_session_count, 1);
22371 assert_eq!(lease_snapshot.managed_record_count, 0);
22372 shared.persist_state().unwrap();
22373 assert!(shared
22374 .managed_worktrees
22375 .lock()
22376 .unwrap_or_else(|poisoned| poisoned.into_inner())
22377 .get(&lease.lease_id)
22378 .unwrap()
22379 .record_holders
22380 .is_empty());
22381 shared
22382 .handle
22383 .dispatch(shared.prepare_command(ControlCommand::Register {
22384 instance_id: address.session.instance_id,
22385 agent_id: agent("claude"),
22386 transport: TransportKind::Pty,
22387 }))
22388 .unwrap();
22389 runtime.tick().await;
22390 shared.reconcile_managed_record(
22391 &record_id,
22392 &address,
22393 &ControlEvent {
22394 sequence: 1,
22395 command_id: None,
22396 instance_id: address.session.instance_id,
22397 generation: address.session.generation,
22398 event: ControlEventKind::Removed,
22399 },
22400 false,
22401 false,
22402 );
22403 let remove = shared.remove_session(&address);
22404 let drive_runtime = async {
22405 runtime.tick().await;
22406 };
22407 let (removed, ()) = tokio::join!(remove, drive_runtime);
22408 removed.unwrap();
22409
22410 assert!(!materialization_root.exists());
22411 assert!(!Path::new(&created.path).exists());
22412 assert_eq!(
22413 shared
22414 .managed_worktrees
22415 .lock()
22416 .unwrap_or_else(|poisoned| poisoned.into_inner())
22417 .get(&lease.lease_id)
22418 .unwrap()
22419 .state,
22420 ManagedWorktreeLeaseState::Removed,
22421 );
22422 let renamed = shared
22423 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22424 .unwrap();
22425 assert_eq!(renamed.record_id, record_id);
22426 assert_eq!(renamed.display_name, "raw managed inventory");
22427 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22428 assert!(renamed.active_session.is_none());
22429
22430 drop(shared);
22431 drop(runtime);
22432 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22433 std::fs::remove_dir_all(git_root).unwrap();
22434 }
22435
22436 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22437 let root = std::env::current_dir().unwrap()
22438 .join("managed-test-target")
22439 .to_string_lossy()
22440 .into_owned();
22441 let lease = ManagedWorktreeLeaseRecord {
22442 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22443 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22444 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22445 profile_id: WorktreeProfileId::new("default").unwrap(),
22446 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22447 target_root: root.clone(),
22448 branch: "gate4agent/mw-test".to_owned(),
22449 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22450 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22451 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22452 state: ManagedWorktreeLeaseState::Ready,
22453 session_holders: Vec::new(),
22454 record_holders: Vec::new(),
22455 cleanup_failure: None,
22456 created_at_unix_ms: 1,
22457 updated_at_unix_ms: 1,
22458 };
22459 shared.workspaces.write()
22460 .unwrap_or_else(|poisoned| poisoned.into_inner())
22461 .insert(lease.workspace_id.clone(), root);
22462 *shared.managed_worktrees.lock()
22463 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22464 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22465 lease
22466 }
22467
22468 #[cfg(feature = "fixture")]
22469 #[test]
22470 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22471 let mut shared = terminal_test_shared();
22472 shared.fixture_semantic_hook_policy = true;
22473 let lease = install_managed_lease(&shared);
22474 let address = SessionAddress {
22475 workspace_id: lease.workspace_id.clone(),
22476 session: SessionKey {
22477 instance_id: AgentInstanceId(206),
22478 generation: SessionGeneration(1),
22479 },
22480 };
22481 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22482 let effective_runtime_policy =
22483 shared.effective_spawn_runtime_policy(pre_admission_policy);
22484 assert_ne!(effective_runtime_policy, pre_admission_policy);
22485 assert!(effective_runtime_policy.provider_session_identity);
22486
22487 let record_id = shared
22488 .bind_spawn_session(
22489 &address,
22490 agent("claude"),
22491 SessionMode::Pty,
22492 effective_runtime_policy,
22493 SpawnRecordPolicy::Always,
22494 None,
22495 )
22496 .unwrap()
22497 .unwrap();
22498 assert_eq!(
22499 shared.record(&record_id).unwrap().state,
22500 ManagedSessionState::IdentityPending,
22501 );
22502 let (record_holder, raw_inventory_record_id) = {
22503 let mut bindings = shared
22504 .session_bindings
22505 .lock()
22506 .unwrap_or_else(|poisoned| poisoned.into_inner());
22507 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22508 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22509 managed_spawn_record_ownership(binding, effective_runtime_policy)
22510 };
22511 assert_eq!(record_holder.as_ref(), Some(&record_id));
22512 assert!(raw_inventory_record_id.is_none());
22513 let snapshot = shared
22514 .managed_worktrees
22515 .lock()
22516 .unwrap_or_else(|poisoned| poisoned.into_inner())
22517 .bind_session(
22518 &lease.lease_id,
22519 ManagedWorktreeSessionHolder {
22520 incarnation_id: shared.incarnation_id,
22521 instance_id: address.session.instance_id,
22522 generation: address.session.generation,
22523 },
22524 record_holder,
22525 unix_time_ms(),
22526 )
22527 .unwrap();
22528 assert_eq!(snapshot.managed_record_count, 1);
22529 assert_eq!(
22530 shared
22531 .managed_worktrees
22532 .lock()
22533 .unwrap_or_else(|poisoned| poisoned.into_inner())
22534 .get(&lease.lease_id)
22535 .unwrap()
22536 .record_holders,
22537 vec![record_id.clone()],
22538 );
22539
22540 let identity = ProviderSessionIdentity {
22541 key: ProviderSessionKey::SessionId,
22542 id: "monitoring-fixture-managed-session".to_owned(),
22543 transcript_path: None,
22544 };
22545 shared.publish_control(ControlEvent {
22546 sequence: 1,
22547 command_id: None,
22548 instance_id: address.session.instance_id,
22549 generation: address.session.generation,
22550 event: ControlEventKind::ProviderEvent {
22551 sequence: 1,
22552 source: gate4agent_types::ProviderSource {
22553 family: AdapterFamily::Hook,
22554 binding: AdapterBinding::new(
22555 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22556 "fixture/v1",
22557 gate4agent_types::AdapterVerification::SyntheticFixture,
22558 )
22559 .unwrap(),
22560 },
22561 source_sequence: 1,
22562 event: ProviderEvent::SessionIdentityObserved {
22563 identity: identity.clone(),
22564 },
22565 },
22566 });
22567
22568 let live = shared.record(&record_id).unwrap();
22569 assert_eq!(live.state, ManagedSessionState::Live);
22570 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22571 assert_eq!(live.active_session.as_ref(), Some(&address));
22572 assert_eq!(
22573 shared
22574 .managed_worktrees
22575 .lock()
22576 .unwrap_or_else(|poisoned| poisoned.into_inner())
22577 .get(&lease.lease_id)
22578 .unwrap()
22579 .record_holders,
22580 vec![record_id],
22581 );
22582 }
22583
22584 fn managed_v2_test_shared(
22585 allocation_root: &Path,
22586 state_path: Option<PathBuf>,
22587 profile_revision: &str,
22588 ) -> NodeShared {
22589 let profile = ManagedWorktreeProfile::new(
22590 WorktreeProfileId::new("default").unwrap(),
22591 WorktreeProfileRevision::new(profile_revision).unwrap(),
22592 allocation_root,
22593 "gate4agent",
22594 "HEAD",
22595 ManagedWorktreeRetention::RemoveWhenReleased,
22596 )
22597 .unwrap();
22598 let workspace = WorkspaceConfig::new(
22599 WorkspaceId::new("primary").unwrap(),
22600 std::env::current_dir().unwrap(),
22601 )
22602 .unwrap()
22603 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22604 .with_managed_worktree_profile(profile)
22605 .unwrap();
22606 let catalog = active_registry().unwrap();
22607 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22608 let mut shared = NodeShared::new(
22609 handle,
22610 "fixture-token".to_owned(),
22611 NodeId::new("node-terminal-test").unwrap(),
22612 vec![workspace],
22613 vec![agent("claude")],
22614 );
22615 shared.state_path = state_path;
22616 shared
22617 }
22618
22619 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22620 let mut spawn_spec = spawn_spec_fixture();
22621 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22622 ManagedWorktreeSpawnRequestV2 {
22623 spawn_spec,
22624 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22625 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22626 }
22627 }
22628
22629 #[test]
22630 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22631 let shared = terminal_test_shared();
22632 let mut lease = install_managed_lease(&shared);
22633 let session = SessionAddress {
22634 workspace_id: lease.workspace_id.clone(),
22635 session: SessionKey {
22636 instance_id: AgentInstanceId(73),
22637 generation: SessionGeneration(4),
22638 },
22639 };
22640 lease.state = ManagedWorktreeLeaseState::InUse;
22641 lease.session_holders.push(ManagedWorktreeSessionHolder {
22642 incarnation_id: shared.incarnation_id,
22643 instance_id: session.session.instance_id,
22644 generation: session.session.generation,
22645 });
22646 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22647 assert!(resolved.target.worktree_id.is_none());
22648
22649 let receipt = managed_spawn_receipt(
22650 &resolved,
22651 shared.incarnation_id,
22652 session.clone(),
22653 lease.snapshot(),
22654 None,
22655 None,
22656 None,
22657 );
22658 assert!(resolved.target.worktree_id.is_none());
22659 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22660 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22661 assert_eq!(receipt.spawn.session, session);
22662 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22663 let encoded = serde_json::to_vec(&receipt).unwrap();
22664 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22665 assert_eq!(decoded, receipt);
22666 }
22667
22668 #[tokio::test]
22669 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22670 let root = temporary_workspace_root("managed-v2-revision");
22671 let allocation = root.join("allocation");
22672 std::fs::create_dir_all(&allocation).unwrap();
22673 let shared = managed_v2_test_shared(
22674 &allocation,
22675 Some(root.join("node-state.json")),
22676 "v1",
22677 );
22678 let mut request = managed_v2_request("managed-v2-revision");
22679 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22680
22681 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22682
22683 assert_eq!(
22684 failure.code,
22685 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22686 );
22687 assert!(shared
22688 .managed_worktrees
22689 .lock()
22690 .unwrap_or_else(|poisoned| poisoned.into_inner())
22691 .records()
22692 .is_empty());
22693 assert!(shared
22694 .managed_spawn_replays
22695 .lock()
22696 .unwrap_or_else(|poisoned| poisoned.into_inner())
22697 .is_empty());
22698 std::fs::remove_dir_all(root).unwrap();
22699 }
22700
22701 #[test]
22702 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22703 let root = temporary_workspace_root("managed-v2-concurrent");
22704 let allocation = root.join("allocation");
22705 std::fs::create_dir_all(&allocation).unwrap();
22706 let shared = Arc::new(managed_v2_test_shared(
22707 &allocation,
22708 Some(root.join("node-state.json")),
22709 "v1",
22710 ));
22711 let request = managed_v2_request("managed-v2-concurrent");
22712 let barrier = Arc::new(std::sync::Barrier::new(3));
22713 let mut joins = Vec::new();
22714 for _ in 0..2 {
22715 let shared = Arc::clone(&shared);
22716 let request = request.clone();
22717 let barrier = Arc::clone(&barrier);
22718 joins.push(std::thread::spawn(move || {
22719 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22720 barrier.wait();
22721 shared.acquire_durable_managed_spawn_v2(&request, digest)
22722 }));
22723 }
22724 barrier.wait();
22725 let results = joins
22726 .into_iter()
22727 .map(|join| join.join().unwrap())
22728 .collect::<Vec<_>>();
22729 assert_eq!(
22730 results
22731 .iter()
22732 .filter(|result| {
22733 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22734 })
22735 .count(),
22736 1,
22737 );
22738 assert_eq!(
22739 results
22740 .iter()
22741 .filter(|result| {
22742 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22743 })
22744 .count(),
22745 1,
22746 );
22747 assert_eq!(
22748 shared
22749 .managed_spawn_replays
22750 .lock()
22751 .unwrap_or_else(|poisoned| poisoned.into_inner())
22752 .len(),
22753 1,
22754 );
22755 assert!(shared
22756 .managed_worktrees
22757 .lock()
22758 .unwrap_or_else(|poisoned| poisoned.into_inner())
22759 .records()
22760 .is_empty());
22761 drop(shared);
22762 std::fs::remove_dir_all(root).unwrap();
22763 }
22764
22765 #[tokio::test]
22766 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22767 let root = temporary_workspace_root("managed-v2-linked-pending");
22768 let allocation = root.join("allocation");
22769 std::fs::create_dir_all(&allocation).unwrap();
22770 let state_path = root.join("node-state.json");
22771 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22772 let lease = install_managed_lease(&shared);
22773 let request = managed_v2_request("managed-v2-linked-pending");
22774 assert!(matches!(
22775 shared
22776 .acquire_durable_managed_spawn_v2(
22777 &request,
22778 managed_spawn_request_digest_v2(&request).unwrap(),
22779 )
22780 .unwrap(),
22781 DurableManagedSpawnReplayDecision::Reserved,
22782 ));
22783 {
22784 let _transaction = shared
22785 .state_transaction
22786 .lock()
22787 .unwrap_or_else(|poisoned| poisoned.into_inner());
22788 shared
22789 .link_durable_managed_spawn_v2_lease_locked(
22790 &request.spawn_spec.idempotency_key,
22791 &lease.lease_id,
22792 )
22793 .unwrap();
22794 shared.persist_state_locked().unwrap();
22795 }
22796 drop(shared);
22797
22798 let mut loaded = session_registry::load(
22799 Some(&state_path),
22800 &NodeId::new("node-terminal-test").unwrap(),
22801 )
22802 .unwrap();
22803 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22804 *reopened
22805 .managed_worktrees
22806 .lock()
22807 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22808 ManagedWorktreeRegistry::from_records(
22809 std::mem::take(&mut loaded.managed_worktrees),
22810 std::mem::take(&mut loaded.managed_worktree_tombstones),
22811 )
22812 .unwrap();
22813 *reopened
22814 .managed_spawn_replays
22815 .lock()
22816 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22817 .managed_spawn_replays
22818 .into_iter()
22819 .map(|record| (record.idempotency_key.clone(), record))
22820 .collect();
22821 let lease_count = reopened
22822 .managed_worktrees
22823 .lock()
22824 .unwrap_or_else(|poisoned| poisoned.into_inner())
22825 .records()
22826 .len();
22827
22828 let failure = reopened
22829 .spawn_managed_worktree_v2(request)
22830 .await
22831 .unwrap_err();
22832
22833 assert_eq!(
22834 failure.code,
22835 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22836 );
22837 assert_eq!(
22838 reopened
22839 .managed_worktrees
22840 .lock()
22841 .unwrap_or_else(|poisoned| poisoned.into_inner())
22842 .records()
22843 .len(),
22844 lease_count,
22845 );
22846 std::fs::remove_dir_all(root).unwrap();
22847 }
22848
22849 #[tokio::test]
22850 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22851 let root = temporary_workspace_root("managed-v2-reopen");
22852 let allocation = root.join("allocation");
22853 std::fs::create_dir_all(&allocation).unwrap();
22854 let state_path = root.join("node-state.json");
22855 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22856 let lease = install_managed_lease(&shared);
22857 let request = managed_v2_request("managed-v2-reopen");
22858 let session = SessionAddress {
22859 workspace_id: lease.workspace_id.clone(),
22860 session: SessionKey {
22861 instance_id: AgentInstanceId(91),
22862 generation: SessionGeneration(1),
22863 },
22864 };
22865 let snapshot = {
22866 let mut registry = shared
22867 .managed_worktrees
22868 .lock()
22869 .unwrap_or_else(|poisoned| poisoned.into_inner());
22870 let current = registry.get_mut(&lease.lease_id).unwrap();
22871 current.state = ManagedWorktreeLeaseState::InUse;
22872 current.session_holders.push(ManagedWorktreeSessionHolder {
22873 incarnation_id: shared.incarnation_id,
22874 instance_id: session.session.instance_id,
22875 generation: session.session.generation,
22876 });
22877 current.snapshot()
22878 };
22879 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22880 let receipt = managed_spawn_receipt(
22881 &resolved,
22882 shared.incarnation_id,
22883 session,
22884 snapshot,
22885 None,
22886 None,
22887 None,
22888 );
22889 assert!(matches!(
22890 shared
22891 .acquire_durable_managed_spawn_v2(
22892 &request,
22893 managed_spawn_request_digest_v2(&request).unwrap(),
22894 )
22895 .unwrap(),
22896 DurableManagedSpawnReplayDecision::Reserved,
22897 ));
22898 {
22899 let _transaction = shared
22900 .state_transaction
22901 .lock()
22902 .unwrap_or_else(|poisoned| poisoned.into_inner());
22903 shared
22904 .link_durable_managed_spawn_v2_lease_locked(
22905 &request.spawn_spec.idempotency_key,
22906 &lease.lease_id,
22907 )
22908 .unwrap();
22909 shared.persist_state_locked().unwrap();
22910 }
22911 shared
22912 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22913 .unwrap();
22914 let legacy_same_key = ManagedWorktreeSpawnRequest {
22915 spawn_spec: request.spawn_spec.clone(),
22916 worktree_profile_id: request.worktree_profile_id.clone(),
22917 };
22918 shared.remember_managed_spawn_attempt(
22919 Some(&request.spawn_spec.idempotency_key),
22920 legacy_same_key.clone(),
22921 Ok(receipt.clone()),
22922 Instant::now(),
22923 );
22924 assert!(shared
22925 .replay_managed_spawn(&legacy_same_key, Instant::now())
22926 .unwrap()
22927 .is_none());
22928 drop(shared);
22929
22930 let mut loaded = session_registry::load(
22931 Some(&state_path),
22932 &NodeId::new("node-terminal-test").unwrap(),
22933 )
22934 .unwrap();
22935 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22936 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22937 *reopened
22938 .managed_worktrees
22939 .lock()
22940 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22941 ManagedWorktreeRegistry::from_records(
22942 std::mem::take(&mut loaded.managed_worktrees),
22943 std::mem::take(&mut loaded.managed_worktree_tombstones),
22944 )
22945 .unwrap();
22946 *reopened
22947 .managed_spawn_replays
22948 .lock()
22949 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22950 .managed_spawn_replays
22951 .into_iter()
22952 .map(|record| (record.idempotency_key.clone(), record))
22953 .collect();
22954 let lease_count_before = reopened
22955 .managed_worktrees
22956 .lock()
22957 .unwrap_or_else(|poisoned| poisoned.into_inner())
22958 .records()
22959 .len();
22960
22961 assert_eq!(
22962 reopened
22963 .spawn_managed_worktree_v2(request.clone())
22964 .await
22965 .unwrap(),
22966 receipt,
22967 );
22968 assert_eq!(
22969 reopened
22970 .managed_worktrees
22971 .lock()
22972 .unwrap_or_else(|poisoned| poisoned.into_inner())
22973 .records()
22974 .len(),
22975 lease_count_before,
22976 );
22977 let mut changed = request;
22978 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22979 let failure = reopened
22980 .spawn_managed_worktree_v2(changed)
22981 .await
22982 .unwrap_err();
22983 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22984 assert_eq!(
22985 reopened
22986 .managed_worktrees
22987 .lock()
22988 .unwrap_or_else(|poisoned| poisoned.into_inner())
22989 .records()
22990 .len(),
22991 lease_count_before,
22992 );
22993 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22994 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22995 let failure = reopened
22996 .spawn_managed_worktree_v2(new_key)
22997 .await
22998 .unwrap_err();
22999 assert_eq!(
23000 failure.code,
23001 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
23002 );
23003 assert_eq!(
23004 reopened
23005 .managed_worktrees
23006 .lock()
23007 .unwrap_or_else(|poisoned| poisoned.into_inner())
23008 .records()
23009 .len(),
23010 lease_count_before,
23011 );
23012 assert_eq!(
23013 reopened
23014 .managed_spawn_replays
23015 .lock()
23016 .unwrap_or_else(|poisoned| poisoned.into_inner())
23017 .len(),
23018 1,
23019 );
23020 std::fs::remove_dir_all(root).unwrap();
23021 }
23022
23023 #[test]
23024 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
23025 let shared = terminal_test_shared();
23026 let request = ManagedWorktreeSpawnRequest {
23027 spawn_spec: spawn_spec_fixture(),
23028 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
23029 };
23030 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
23031 shared.remember_managed_spawn(
23032 request.clone(),
23033 Err(expected.clone()),
23034 Instant::now(),
23035 );
23036
23037 assert_eq!(
23038 shared
23039 .replay_managed_spawn(&request, Instant::now())
23040 .unwrap()
23041 .unwrap(),
23042 Err(expected),
23043 );
23044 assert!(shared
23045 .managed_spawn_replays
23046 .lock()
23047 .unwrap_or_else(|poisoned| poisoned.into_inner())
23048 .is_empty());
23049 }
23050
23051 #[tokio::test]
23052 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
23053 let shared = terminal_test_shared();
23054 let lease = install_managed_lease(&shared);
23055 let raw = shared.spawn_session(
23056 lease.workspace_id.clone(),
23057 agent("claude"),
23058 SessionMode::Pty,
23059 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
23060 None,
23061 ).await.unwrap_err();
23062 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23063 let mut spec = spawn_spec_fixture();
23064 spec.target.workspace_id = lease.workspace_id.clone();
23065 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
23066 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23067 let mut selected = spawn_spec_fixture();
23068 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
23069 selected.target.worktree_id = Some(lease.workspace_id);
23070 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
23071 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23072 assert!(shared.handle.snapshot().sessions.is_empty());
23073 }
23074
23075 #[test]
23076 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23077 let shared = terminal_test_shared();
23078 let lease = install_managed_lease(&shared);
23079 let snapshot = snapshot_for_wire(
23080 &shared, true, true, true, false, true, true, false, false, true,
23081 );
23082 assert!(snapshot.managed_worktrees.is_empty());
23083 assert!(snapshot.workspaces.iter().all(|workspace| {
23084 workspace.managed_worktree_profiles.is_none()
23085 }));
23086 let mut reply = ResponseEnvelope {
23087 request_id: 1,
23088 result: Ok(NodeResponse::Resync {
23089 event_sequence: 1,
23090 oldest_available_sequence: 1,
23091 snapshot: shared.snapshot(),
23092 events: vec![NodeEventEnvelope {
23093 sequence: 1,
23094 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23095 }],
23096 }),
23097 };
23098 project_response_without_managed_worktrees(&mut reply);
23099 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23100 panic!("projection changed the response kind");
23101 };
23102 assert!(snapshot.managed_worktrees.is_empty());
23103 assert!(events.is_empty());
23104 let mut git = git_snapshot_for_parser();
23105 git.branch = Some(lease.branch.clone());
23106 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23107 lease_id: lease.lease_id.clone(),
23108 source_workspace_id: lease.source_workspace_id.clone(),
23109 branch: lease.branch.clone(),
23110 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23111 active_session_count: 1,
23112 managed_record_count: 0,
23113 });
23114 let mut inspection_reply = ResponseEnvelope {
23115 request_id: 2,
23116 result: Ok(NodeResponse::WorkspaceInspected {
23117 inspection: WorkspaceInspection {
23118 workspace_id: lease.workspace_id.clone(),
23119 entries: Vec::new(),
23120 tree_truncated: false,
23121 git,
23122 truncation: None,
23123 },
23124 }),
23125 };
23126 project_response_without_managed_worktrees(&mut inspection_reply);
23127 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23128 panic!("projection changed the inspection response kind");
23129 };
23130 assert!(inspection.git.managed_worktree.is_none());
23131 let failure = managed_git_worktree_failure(GitWorktreeError {
23132 kind: GitWorktreeErrorKind::Failed,
23133 message: r"git failed at C:\private\managed-target".to_owned(),
23134 });
23135 assert!(!failure.message.contains("private"));
23136 assert!(!failure.message.contains("managed-target"));
23137 }
23138
23139 #[tokio::test]
23140 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23141 let root = temporary_workspace_root("managed-restart");
23142 let source = root.join("source");
23143 let allocation = root.join("allocation");
23144 std::fs::create_dir_all(&source).unwrap();
23145 std::fs::create_dir_all(&allocation).unwrap();
23146 run_test_git(&source, &["init"]);
23147 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23148 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23149 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23150 run_test_git(&source, &["add", "seed.txt"]);
23151 run_test_git(&source, &["commit", "-m", "seed"]);
23152 let profile = ManagedWorktreeProfile::new(
23153 WorktreeProfileId::new("default").unwrap(),
23154 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23155 &allocation,
23156 "gate4agent",
23157 "HEAD",
23158 ManagedWorktreeRetention::Retain,
23159 ).unwrap();
23160 let workspace = WorkspaceConfig::new(
23161 WorkspaceId::new("primary").unwrap(),
23162 &source,
23163 ).unwrap()
23164 .with_worktree_service_mode(WorktreeServiceMode::Managed)
23165 .with_managed_worktree_profile(profile.clone()).unwrap();
23166 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23167 let source_text = workspace.canonical_root().to_owned();
23168 let target_text = target.to_string_lossy().into_owned();
23169 let created = create_git_worktree(
23170 &source_text,
23171 &target_text,
23172 "gate4agent/mw-restart",
23173 Some("HEAD"),
23174 ).await.unwrap();
23175 let lease = ManagedWorktreeLeaseRecord {
23176 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23177 source_workspace_id: WorkspaceId::new("primary").unwrap(),
23178 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23179 profile_id: profile.profile_id().clone(),
23180 profile_revision: profile.revision().clone(),
23181 target_root: created.path.clone(),
23182 branch: "gate4agent/mw-restart".to_owned(),
23183 base_commit: created.head.clone(),
23184 expected_head: None,
23185 retention: ManagedWorktreeRetention::Retain,
23186 state: ManagedWorktreeLeaseState::Allocating,
23187 session_holders: Vec::new(),
23188 record_holders: Vec::new(),
23189 cleanup_failure: None,
23190 created_at_unix_ms: 1,
23191 updated_at_unix_ms: 1,
23192 };
23193 let catalog = active_registry().unwrap();
23194 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23195 let shared = NodeShared::new_with_incarnation(
23196 handle,
23197 "fixture-token".to_owned(),
23198 NodeId::new("node-managed-restart").unwrap(),
23199 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23200 vec![workspace],
23201 vec![agent("claude")],
23202 ProviderRuntimeStatuses::default(),
23203 None,
23204 Vec::new(),
23205 Vec::new(),
23206 SpawnProfileRegistry::default(),
23207 None,
23208 None,
23209 None,
23210 None,
23211 Vec::new(),
23212 vec![lease.clone()],
23213 Vec::new(),
23214 Vec::new(),
23215 None,
23216 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23217 );
23218 shared.reconcile_managed_worktrees().await;
23219 let retained = shared.managed_worktrees.lock()
23220 .unwrap_or_else(|poisoned| poisoned.into_inner())
23221 .get(&lease.lease_id).unwrap().clone();
23222 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23223 assert!(shared.workspaces.read()
23224 .unwrap_or_else(|poisoned| poisoned.into_inner())
23225 .contains_key(&lease.workspace_id));
23226 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23227 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23228 assert!(!Path::new(&created.path).exists());
23229 std::fs::remove_dir_all(root).unwrap();
23230 }
23231
23232 fn run_test_git(root: &Path, arguments: &[&str]) {
23233 let output = std::process::Command::new("git")
23234 .arg("-C").arg(root).args(arguments).output().unwrap();
23235 assert!(
23236 output.status.success(),
23237 "git {:?} failed: {}",
23238 arguments,
23239 String::from_utf8_lossy(&output.stderr),
23240 );
23241 }
23242
23243 #[tokio::test]
23244 async fn spawn_spec_validation_fails_before_session_mutation() {
23245 let shared = terminal_test_shared();
23246 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23247 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23248 }));
23249 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23250 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23251 }));
23252 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23253 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23254 }));
23255
23256 let mut target_mismatch = spawn_spec_fixture();
23257 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23258 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23259 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23260
23261 let mut unknown_profile = spawn_spec_fixture();
23262 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23263 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23264 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23265 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23266
23267 let mut revision_mismatch = spawn_spec_fixture();
23268 revision_mismatch.idempotency_key =
23269 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23270 revision_mismatch.expected_profile_revision =
23271 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23272 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23273 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23274
23275 let mut unsupported_materializer = spawn_spec_fixture();
23276 unsupported_materializer.idempotency_key =
23277 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23278 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23279 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23280 };
23281 let failure = shared
23282 .spawn_from_spec(unsupported_materializer)
23283 .await
23284 .unwrap_err();
23285 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23286
23287 let failure = shared
23288 .spawn_session_with_deadline(
23289 WorkspaceId::new("primary").unwrap(),
23290 agent("claude"),
23291 SessionMode::Pty,
23292 gate4agent_types::TerminalSize {
23293 rows: 24,
23294 columns: 80,
23295 },
23296 None,
23297 None,
23298 None,
23299 None,
23300 None,
23301 None,
23302 SpawnRecordPolicy::ProviderIdentityOnly,
23303 Some(Instant::now()),
23304 &[],
23305 None,
23306 ApprovalLevel::default(),
23307 None,
23308 None,
23309 )
23310 .await
23311 .unwrap_err();
23312 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23313 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23314 assert!(shared.handle.snapshot().sessions.is_empty());
23315 }
23316
23317 #[test]
23318 fn worktree_selection_requires_its_separate_negotiated_capability() {
23319 let mut spec = spawn_spec_fixture();
23320 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23321 let request = NodeRequest::SpawnSpec { spec };
23322 let spawn_spec_capability =
23323 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23324 assert!(request_uses_unnegotiated_capability(
23325 &request,
23326 &[spawn_spec_capability.clone()],
23327 ));
23328 let worktree_capability =
23329 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23330 assert!(request_uses_unnegotiated_capability(
23331 &request,
23332 &[spawn_spec_capability.clone(), worktree_capability.clone()],
23333 ));
23334 let profile_revision_capability =
23335 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23336 assert!(request_uses_unnegotiated_capability(
23337 &request,
23338 &[
23339 spawn_spec_capability.clone(),
23340 worktree_capability.clone(),
23341 profile_revision_capability.clone(),
23342 ],
23343 ));
23344 let bundle_materialization_capability =
23351 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23352 assert!(request_uses_unnegotiated_capability(
23353 &request,
23354 &[
23355 spawn_spec_capability.clone(),
23356 worktree_capability.clone(),
23357 profile_revision_capability.clone(),
23358 bundle_materialization_capability.clone(),
23359 ],
23360 ));
23361 let history_context_pack_capability =
23362 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23363 assert!(!request_uses_unnegotiated_capability(
23364 &request,
23365 &[
23366 spawn_spec_capability,
23367 worktree_capability,
23368 profile_revision_capability,
23369 bundle_materialization_capability,
23370 history_context_pack_capability,
23371 ],
23372 ));
23373 }
23374
23375 #[tokio::test]
23376 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23377 let mut shared = terminal_test_shared();
23378 let spec = spawn_spec_fixture();
23379 let receipt = shared
23380 .resolve_spawn_spec(&spec)
23381 .unwrap()
23382 .receipt(shared.incarnation_id, terminal_address(1));
23383 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23384
23385 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23386 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23387 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23388
23389 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23390
23391 let mut revision_conflicting = spec.clone();
23392 revision_conflicting.expected_profile_revision =
23393 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23394 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23395 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23396
23397 let mut stale_cache_miss = spec.clone();
23398 stale_cache_miss.idempotency_key =
23399 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23400 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23401 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23402
23403 let mut conflicting = spec;
23404 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23405 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23406 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23407 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23408 assert!(shared.handle.snapshot().sessions.is_empty());
23409 }
23410
23411 #[tokio::test]
23412 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23413 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23414 let mut spec = spawn_spec_fixture();
23415 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23416 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23417 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23418
23419 let failure = shared
23420 .create_worktree(
23421 WorkspaceId::new("primary").unwrap(),
23422 WorkspaceId::new("new-worktree").unwrap(),
23423 "not-an-absolute-path".to_owned(),
23424 "invalid branch".to_owned(),
23425 None,
23426 )
23427 .await
23428 .unwrap_err();
23429 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23430
23431 let failure = shared
23432 .remove_worktree(
23433 WorkspaceId::new("primary").unwrap(),
23434 "not-an-absolute-path".to_owned(),
23435 )
23436 .await
23437 .unwrap_err();
23438 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23439 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23440 assert!(shared.handle.snapshot().sessions.is_empty());
23441 }
23442
23443 #[tokio::test]
23444 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23445 let root = temporary_workspace_root("manual-worktree-compensation");
23446 let source = root.join("source");
23447 let target = root.join("target");
23448 let invalid_state_parent = root.join("state-parent-is-a-file");
23449 let invalid_state_path = invalid_state_parent.join("state.json");
23450 std::fs::create_dir_all(&source).unwrap();
23451 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23452 run_test_git(&source, &["init"]);
23453 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23454 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23455 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23456 run_test_git(&source, &["add", "seed.txt"]);
23457 run_test_git(&source, &["commit", "-m", "seed"]);
23458
23459 let catalog = active_registry().unwrap();
23460 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23461 let workspace = WorkspaceConfig::new(
23462 WorkspaceId::new("primary").unwrap(),
23463 &source,
23464 ).unwrap();
23465 let mut shared = NodeShared::new(
23466 handle,
23467 "fixture-token".to_owned(),
23468 NodeId::new("node-worktree-compensation").unwrap(),
23469 vec![workspace],
23470 vec![agent("claude")],
23471 );
23472 shared.state_path = Some(invalid_state_path);
23473
23474 let error = shared.create_worktree(
23475 WorkspaceId::new("primary").unwrap(),
23476 WorkspaceId::new("compensated").unwrap(),
23477 target.to_string_lossy().into_owned(),
23478 "gate4agent/compensated".to_owned(),
23479 Some("HEAD".to_owned()),
23480 ).await.unwrap_err();
23481
23482 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23483 assert!(!target.exists());
23484 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23485 .iter()
23486 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23487 std::fs::remove_dir_all(root).unwrap();
23488 }
23489
23490 #[test]
23491 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23492 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23493 NativeGitWorktreeSnapshot {
23494 path: path.to_owned(),
23495 head: "0123456789abcdef".to_owned(),
23496 branch: Some("refs/heads/test".to_owned()),
23497 is_bare,
23498 is_main,
23499 locked: false,
23500 lock_reason: None,
23501 prunable: false,
23502 prunable_reason: None,
23503 workspace_id: None,
23504 }
23505 }
23506
23507 let source = r"C:\repo";
23508 let selected = r"C:\repo-worktree";
23509 let listed = vec![
23510 worktree(source, true, false),
23511 worktree(selected, false, false),
23512 ];
23513 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23514
23515 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23516 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23517
23518 let bare = vec![
23519 worktree(source, true, false),
23520 worktree(selected, false, true),
23521 ];
23522 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23523 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23524
23525 let foreign_failure =
23526 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23527 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23528 }
23529
23530 fn terminal_frame(sequence: u64) -> TerminalFrame {
23531 TerminalFrame {
23532 sequence,
23533 size: gate4agent_types::TerminalSize {
23534 rows: 24,
23535 columns: 80,
23536 },
23537 cursor_row: 2,
23538 cursor_column: 3,
23539 contents: format!("frame-{sequence}"),
23540 formatted: vec![1, 2, 3],
23541 scrollback_formatted: Vec::new(),
23542 alternate_screen: false,
23543 mouse_protocol_enabled: false,
23544 mouse_protocol_encoding: Default::default(),
23545 produced_at_unix_ms: 0,
23546 screen_state: gate4agent_types::PtyScreenState::default(),
23547 bracketed_paste: None,
23548 }
23549 }
23550
23551 #[test]
23552 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23553 let shared = terminal_test_shared();
23554 let mut receiver = shared.terminal_event_tx.subscribe();
23555 let original = terminal_address(1);
23556
23557 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23558 let first = receiver.try_recv().unwrap();
23559 assert_eq!(first.len(), 1);
23560 assert!(matches!(
23561 &first[0].event,
23562 NodeEvent::TerminalFrame { address, frame }
23563 if address == &original && frame.sequence == 7
23564 ));
23565
23566 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23567 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23568 assert!(matches!(
23569 receiver.try_recv(),
23570 Err(broadcast::error::TryRecvError::Empty)
23571 ));
23572
23573 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23574 let advanced = receiver.try_recv().unwrap();
23575 assert_eq!(advanced[0].sequence, 2);
23576
23577 let resumed = terminal_address(2);
23578 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23579 let reset = receiver.try_recv().unwrap();
23580 assert_eq!(reset[0].sequence, 3);
23581 assert!(matches!(
23582 &reset[0].event,
23583 NodeEvent::TerminalFrame { address, frame }
23584 if address == &resumed && frame.sequence == 1
23585 ));
23586 shared.clear_terminal_frame_watermark(&resumed);
23587 assert!(!shared
23588 .terminal_frame_watermarks
23589 .lock()
23590 .unwrap_or_else(|poisoned| poisoned.into_inner())
23591 .contains_key(&resumed.session.instance_id));
23592 }
23593
23594 #[test]
23595 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23596 let shared = terminal_test_shared();
23597 let mut receiver = shared.terminal_event_tx.subscribe();
23598 let address = terminal_address(1);
23599 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23600
23601 for sequence in 1..=count {
23602 shared.publish_terminal_frame_candidates(vec![(
23603 address.clone(),
23604 terminal_frame(sequence),
23605 )]);
23606 }
23607
23608 let history = shared
23609 .history
23610 .lock()
23611 .unwrap_or_else(|poisoned| poisoned.into_inner());
23612 assert!(history.events.is_empty());
23613 assert_eq!(history.last_sequence, count);
23614 assert_eq!(history.replay_floor_sequence, 1);
23615 drop(history);
23616 let NodeResponse::Resync {
23617 event_sequence,
23618 oldest_available_sequence,
23619 events,
23620 ..
23621 } = shared.resync(0) else {
23622 unreachable!("resync helper returned another response")
23623 };
23624 assert_eq!(event_sequence, count);
23625 assert_eq!(oldest_available_sequence, 1);
23626 assert!(events.is_empty());
23627 assert!(matches!(
23628 resync_required_event(&shared).event,
23629 NodeEvent::ResyncRequired {
23630 oldest_available_sequence: 1,
23631 }
23632 ));
23633 assert!(matches!(
23634 receiver.try_recv(),
23635 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23636 ));
23637 let latest = receiver.try_recv().unwrap();
23638 assert_eq!(latest.len(), 1);
23639 assert_eq!(latest[0].sequence, count);
23640 }
23641
23642 #[test]
23643 fn real_durable_eviction_advances_authoritative_replay_floor() {
23644 let shared = terminal_test_shared();
23645 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23646 shared.publish(NodeEvent::ControllerChanged { controller: None });
23647 }
23648
23649 let NodeResponse::Resync {
23650 event_sequence,
23651 oldest_available_sequence,
23652 events,
23653 ..
23654 } = shared.resync(0) else {
23655 unreachable!("resync helper returned another response")
23656 };
23657 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23658 assert_eq!(oldest_available_sequence, 2);
23659 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23660 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23661 assert!(matches!(
23662 resync_required_event(&shared).event,
23663 NodeEvent::ResyncRequired {
23664 oldest_available_sequence: 2,
23665 }
23666 ));
23667 }
23668
23669 #[test]
23670 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23671 let shared = terminal_test_shared();
23672 let without_capability = terminal_event_subscription(&shared, false);
23673 let with_capability = terminal_event_subscription(&shared, true);
23674 assert!(without_capability.is_none());
23675 assert!(with_capability.is_some());
23676 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23677 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23678 }));
23679 }
23680
23681 #[test]
23682 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23683 let mut pending = Vec::new();
23684 let mut event_sequence = 0_u64;
23685 for frame_sequence in 1..=4 {
23686 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23687 event_sequence += 1;
23688 let mut address = terminal_address(1);
23689 address.session.instance_id = AgentInstanceId(instance);
23690 queue_connection_event(
23691 &mut pending,
23692 NodeEventEnvelope {
23693 sequence: event_sequence,
23694 event: NodeEvent::TerminalFrame {
23695 address,
23696 frame: terminal_frame(frame_sequence),
23697 },
23698 },
23699 0,
23700 );
23701 }
23702 }
23703 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23704 pending.sort_unstable_by_key(|event| event.sequence);
23705
23706 let mut uninterrupted = pending.clone();
23707 let first_burst = connection_event_burst_len(uninterrupted.len());
23708 uninterrupted.drain(..first_burst);
23709 let second_burst = connection_event_burst_len(uninterrupted.len());
23710 uninterrupted.drain(..second_burst);
23711 assert!(uninterrupted.is_empty());
23712
23713 let (frame_tx, mut frame_rx) =
23714 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23715 frame_tx
23716 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23717 request_id: 77,
23718 request: NodeRequest::Snapshot,
23719 })))
23720 .unwrap();
23721 let burst_len = connection_event_burst_len(pending.len());
23722 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23723 pending.drain(..burst_len);
23724 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23725 assert!(matches!(
23726 frame_rx.try_recv(),
23727 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23728 request_id: 77,
23729 request: NodeRequest::Snapshot,
23730 })))
23731 ));
23732 }
23733
23734 #[test]
23735 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23736 let mut pending = Vec::new();
23737 for sequence in [7_u64, 8] {
23738 let mut address = terminal_address(1);
23739 address.session.instance_id = AgentInstanceId(sequence);
23740 queue_connection_event(
23741 &mut pending,
23742 NodeEventEnvelope {
23743 sequence,
23744 event: NodeEvent::TerminalFrame {
23745 address,
23746 frame: terminal_frame(sequence),
23747 },
23748 },
23749 0,
23750 );
23751 }
23752 let mut discard_through = 0;
23753 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23754 assert!(pending.is_empty());
23755
23756 let replayed = NodeEventEnvelope {
23757 sequence: 8,
23758 event: NodeEvent::TerminalFrame {
23759 address: terminal_address(1),
23760 frame: terminal_frame(8),
23761 },
23762 };
23763 let advanced = NodeEventEnvelope {
23764 sequence: 9,
23765 event: NodeEvent::TerminalFrame {
23766 address: terminal_address(2),
23767 frame: terminal_frame(1),
23768 },
23769 };
23770 queue_connection_event_batch(
23771 &mut pending,
23772 &[replayed, advanced.clone()],
23773 discard_through,
23774 );
23775 assert_eq!(pending, vec![advanced]);
23776 }
23777
23778 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23779 ManagedSessionRecord {
23780 record_id: SessionRecordId::new(record_id).unwrap(),
23781 display_name: format!("{provider} record"),
23782 provider: agent(provider),
23783 mode: SessionMode::Pty,
23784 state: ManagedSessionState::Unavailable,
23785 workspace_id: WorkspaceId::new("primary").unwrap(),
23786 canonical_root: opaque_windows_path(
23787 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23788 ),
23789 provider_session: None,
23790 active_session: None,
23791 environment_profile: None,
23792 bundle: None,
23793 context_id: None,
23794 context: None,
23795 exported_context: None,
23796 task_binding: None,
23797 created_at_unix_ms: 1,
23798 updated_at_unix_ms: 1,
23799 last_error: None,
23800 }
23801 }
23802
23803 fn retention_test_record(
23804 workspace_id: &str,
23805 record_id: &str,
23806 state: ManagedSessionState,
23807 updated_at_unix_ms: u64,
23808 ) -> ManagedSessionRecord {
23809 ManagedSessionRecord {
23810 record_id: SessionRecordId::new(record_id).unwrap(),
23811 display_name: format!("{record_id} display"),
23812 provider: agent("claude"),
23813 mode: SessionMode::Pty,
23814 state,
23815 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23816 canonical_root: opaque_windows_path(
23817 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23818 ),
23819 provider_session: None,
23820 active_session: None,
23821 environment_profile: None,
23822 bundle: None,
23823 context_id: None,
23824 context: None,
23825 exported_context: None,
23826 task_binding: None,
23827 created_at_unix_ms: updated_at_unix_ms,
23828 updated_at_unix_ms,
23829 last_error: None,
23830 }
23831 }
23832
23833 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23841 vec![
23842 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23843 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23844 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23845 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23846 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23847 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23848 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23849 retention_test_record(
23850 "workspace-a",
23851 "decoy-pending",
23852 ManagedSessionState::IdentityPending,
23853 now - 500_000,
23854 ),
23855 retention_test_record(
23856 "workspace-a",
23857 "decoy-dormant",
23858 ManagedSessionState::Dormant,
23859 now - 500_000,
23860 ),
23861 ]
23862 }
23863
23864 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23865 let root = temporary_workspace_root("session-record-retention");
23866 std::fs::create_dir_all(&root).unwrap();
23867 let catalog = active_registry().unwrap();
23868 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23869 let workspace_a =
23870 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23871 let workspace_b =
23872 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23873 let mut shared = NodeShared::new(
23874 handle,
23875 "fixture-token".to_owned(),
23876 NodeId::new("node-retention-test").unwrap(),
23877 vec![workspace_a, workspace_b],
23878 vec![agent("claude")],
23879 );
23880 shared.state_path = Some(root.join("node-state-v10.json"));
23881 let now = unix_time_ms();
23882 for record in retention_fixture_records(now) {
23883 shared.insert_record(record).unwrap();
23884 }
23885 shared
23886 .persist_state_locked()
23887 .expect("fixture-only, empty-history persist against a fresh temp state path");
23888 (shared, runtime, root, now)
23889 }
23890
23891 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23892 shared
23893 .history
23894 .lock()
23895 .unwrap_or_else(|poisoned| poisoned.into_inner())
23896 .events
23897 .iter()
23898 .filter_map(|envelope| match &envelope.event {
23899 NodeEvent::SessionRecordRemoved { record_id } => {
23900 Some(record_id.as_str().to_owned())
23901 }
23902 _ => None,
23903 })
23904 .collect()
23905 }
23906
23907 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23908 shared
23909 .session_records
23910 .lock()
23911 .unwrap_or_else(|poisoned| poisoned.into_inner())
23912 .records
23913 .keys()
23914 .map(|record_id| record_id.as_str().to_owned())
23915 .collect()
23916 }
23917
23918 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23919 values.iter().map(|value| (*value).to_owned()).collect()
23920 }
23921
23922 #[tokio::test]
23923 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23924 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23925 shared.session_record_retention = SessionRecordRetentionConfig {
23926 age_ms: 0,
23927 keep_per_workspace: 1,
23928 };
23929 shared.retire_unavailable_session_records().await;
23930
23931 assert_eq!(
23932 retention_remaining_record_ids(&shared),
23933 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23934 );
23935 assert_eq!(
23936 retention_removed_record_ids(&shared),
23937 owned(&["a1", "a2", "b1", "b2"]),
23938 );
23939 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23940
23941 drop(shared);
23942 drop(runtime);
23943 std::fs::remove_dir_all(root).unwrap();
23944 }
23945
23946 #[tokio::test]
23952 async fn session_record_retention_both_set_retires_only_the_intersection() {
23953 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23954 shared.session_record_retention = SessionRecordRetentionConfig {
23955 age_ms: 250_000,
23956 keep_per_workspace: 1,
23957 };
23958 shared.retire_unavailable_session_records().await;
23959
23960 assert_eq!(
23961 retention_remaining_record_ids(&shared),
23962 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23963 );
23964 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23965 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23966
23967 drop(shared);
23968 drop(runtime);
23969 std::fs::remove_dir_all(root).unwrap();
23970 }
23971
23972 #[tokio::test]
23973 async fn session_record_retention_both_disabled_is_a_no_op() {
23974 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23975 shared.session_record_retention = SessionRecordRetentionConfig::default();
23976 shared.retire_unavailable_session_records().await;
23977
23978 assert_eq!(
23979 retention_remaining_record_ids(&shared),
23980 owned(&[
23981 "a1", "a2", "a3", "b1", "b2", "b3",
23982 "decoy-live", "decoy-pending", "decoy-dormant",
23983 ]),
23984 );
23985 assert!(retention_removed_record_ids(&shared).is_empty());
23986 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23987
23988 drop(shared);
23989 drop(runtime);
23990 std::fs::remove_dir_all(root).unwrap();
23991 }
23992
23993 #[test]
23994 fn session_record_context_export_selects_one_exact_history_identity() {
23995 let candidates = vec![
23996 HistoryCandidateSummary {
23997 id: "candidate-other".to_owned(),
23998 session_id_hint: "provider-other".to_owned(),
23999 modified_at_unix_ms: Some(1),
24000 },
24001 HistoryCandidateSummary {
24002 id: "candidate-exact".to_owned(),
24003 session_id_hint: "provider-exact".to_owned(),
24004 modified_at_unix_ms: Some(2),
24005 },
24006 ];
24007 assert_eq!(
24008 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
24009 "candidate-exact",
24010 );
24011 }
24012
24013 #[test]
24014 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
24015 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24016 id: id.to_owned(),
24017 session_id_hint: hint.to_owned(),
24018 modified_at_unix_ms: None,
24019 };
24020 let missing = select_history_candidate(
24021 &[candidate("candidate-other", "provider-other")],
24022 Some("provider-exact"),
24023 )
24024 .unwrap_err();
24025 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24026 let ambiguous = select_history_candidate(
24027 &[
24028 candidate("candidate-a", "provider-exact"),
24029 candidate("candidate-b", "provider-exact"),
24030 ],
24031 Some("provider-exact"),
24032 )
24033 .unwrap_err();
24034 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24035 }
24036
24037 #[test]
24045 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
24046 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
24047 id: id.to_owned(),
24048 session_id_hint: hint.to_owned(),
24049 modified_at_unix_ms: None,
24050 };
24051 assert_eq!(
24052 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
24053 .unwrap(),
24054 "candidate-sole",
24055 );
24056 let missing = select_history_candidate(&[], None).unwrap_err();
24057 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
24058 let ambiguous = select_history_candidate(
24059 &[
24060 candidate("candidate-a", "sidecar-session-a"),
24061 candidate("candidate-b", "sidecar-session-b"),
24062 ],
24063 None,
24064 )
24065 .unwrap_err();
24066 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
24067 }
24068
24069 #[test]
24070 fn session_record_context_export_revalidation_rejects_record_change() {
24071 let session = terminal_address(1);
24072 let identity = ProviderSessionIdentity {
24073 key: ProviderSessionKey::SessionId,
24074 id: "provider-exact".to_owned(),
24075 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24076 };
24077 let mut expected = record("claude", "record-context-export");
24078 expected.state = ManagedSessionState::Live;
24079 expected.provider_session = Some(identity.clone());
24080 expected.active_session = Some(session.clone());
24081 let binding = SessionBinding {
24082 workspace_id: session.workspace_id.clone(),
24083 generation: session.session.generation,
24084 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24085 pending_resume: None,
24086 record_id: Some(expected.record_id.clone()),
24087 managed_worktree_lease_id: None,
24088 environment_profile: None,
24089 bundle: None,
24090 context: None,
24091 materialization_id: None,
24092 };
24093 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24094 assert!(session_record_context_export_binding_is_exact(
24095 &expected,
24096 &expected,
24097 Some(&identity),
24098 &session,
24099 &binding,
24100 &expected.provider,
24101 ¤t_root,
24102 false,
24103 ));
24104 let mut changed = expected.clone();
24105 changed.updated_at_unix_ms += 1;
24106 assert!(!session_record_context_export_binding_is_exact(
24107 &expected,
24108 &changed,
24109 Some(&identity),
24110 &session,
24111 &binding,
24112 &expected.provider,
24113 ¤t_root,
24114 false,
24115 ));
24116 assert!(!session_record_context_export_binding_is_exact(
24121 &expected,
24122 &changed,
24123 Some(&identity),
24124 &session,
24125 &binding,
24126 &expected.provider,
24127 ¤t_root,
24128 true,
24129 ));
24130 let mut detached = expected.clone();
24131 detached.state = ManagedSessionState::Dormant;
24132 detached.active_session = None;
24133 detached.updated_at_unix_ms += 1;
24134 assert!(!session_record_context_export_binding_is_exact(
24135 &expected,
24136 &detached,
24137 Some(&identity),
24138 &session,
24139 &binding,
24140 &expected.provider,
24141 ¤t_root,
24142 false,
24143 ));
24144 assert!(session_record_context_export_binding_is_exact(
24145 &expected,
24146 &detached,
24147 Some(&identity),
24148 &session,
24149 &binding,
24150 &expected.provider,
24151 ¤t_root,
24152 true,
24153 ));
24154 }
24155
24156 #[test]
24163 fn session_record_context_export_binding_is_exact_without_identity() {
24164 let session = terminal_address(1);
24165 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24166 expected.state = ManagedSessionState::Live;
24167 expected.active_session = Some(session.clone());
24168 let binding = SessionBinding {
24169 workspace_id: session.workspace_id.clone(),
24170 generation: session.session.generation,
24171 runtime_policy: ProviderRuntimePolicy::raw_pty(),
24172 pending_resume: None,
24173 record_id: Some(expected.record_id.clone()),
24174 managed_worktree_lease_id: None,
24175 environment_profile: None,
24176 bundle: None,
24177 context: None,
24178 materialization_id: None,
24179 };
24180 let current_root = windows_path_text(&expected.canonical_root).to_owned();
24181 assert!(session_record_context_export_binding_is_exact(
24182 &expected,
24183 &expected,
24184 None,
24185 &session,
24186 &binding,
24187 &expected.provider,
24188 ¤t_root,
24189 false,
24190 ));
24191 let mut drifted = expected.clone();
24195 drifted.provider_session = Some(ProviderSessionIdentity {
24196 key: ProviderSessionKey::SessionId,
24197 id: "unexpected-identity".to_owned(),
24198 transcript_path: None,
24199 });
24200 assert!(!session_record_context_export_binding_is_exact(
24201 &expected,
24202 &drifted,
24203 None,
24204 &session,
24205 &binding,
24206 &expected.provider,
24207 ¤t_root,
24208 false,
24209 ));
24210 }
24211
24212 #[test]
24213 fn session_record_context_export_preflight_requires_live_usable_source() {
24214 let mut source = record("claude", "record-context-export-preflight");
24215 source.state = ManagedSessionState::Live;
24216 assert!(session_record_context_export_source_is_usable(
24217 &source,
24218 &SessionStatus::Running,
24219 false,
24220 ));
24221 assert!(session_record_context_export_source_is_usable(
24222 &source,
24223 &SessionStatus::Exited { exit_code: Some(0) },
24224 false,
24225 ));
24226
24227 source.state = ManagedSessionState::Dormant;
24228 assert!(!session_record_context_export_source_is_usable(
24229 &source,
24230 &SessionStatus::Running,
24231 false,
24232 ));
24233 assert!(session_record_context_export_source_is_usable(
24237 &source,
24238 &SessionStatus::Exited { exit_code: Some(0) },
24239 true,
24240 ));
24241 assert!(!session_record_context_export_source_is_usable(
24242 &source,
24243 &SessionStatus::Exited { exit_code: Some(0) },
24244 false,
24245 ));
24246 source.state = ManagedSessionState::Live;
24247 assert!(!session_record_context_export_source_is_usable(
24248 &source,
24249 &SessionStatus::Starting,
24250 false,
24251 ));
24252 }
24253
24254 #[test]
24255 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24256 let catalog = active_registry().unwrap();
24257 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24258 let workspace = WorkspaceConfig::new(
24259 WorkspaceId::new("primary").unwrap(),
24260 std::env::current_dir().unwrap(),
24261 )
24262 .unwrap();
24263 let shared = NodeShared::new(
24264 handle,
24265 "fixture-token".to_owned(),
24266 NodeId::new("node-context-export-conflict").unwrap(),
24267 vec![workspace],
24268 vec![agent("claude")],
24269 );
24270 let history = HistorySessionRecord {
24271 session_id: "provider-exact".to_owned(),
24272 title: None,
24273 cwd: None,
24274 model: None,
24275 message_count: 1,
24276 completed_turn_count: None,
24277 total_tokens: 0,
24278 messages: vec![gate4agent_types::HistoryMessageRecord {
24279 role: gate4agent_types::HistoryMessageRole::User,
24280 text: "bounded context".to_owned(),
24281 }],
24282 };
24283 let pack = NodeContextPack::export(
24284 ContextPackLineageReceipt {
24285 source_node_id: shared.node_id.clone(),
24286 source_session: terminal_address(1),
24287 source_provider: agent("claude"),
24288 },
24289 &history,
24290 )
24291 .unwrap();
24292 let context_id = pack.receipt().id.clone();
24293 let session = terminal_address(1);
24294 let identity = ProviderSessionIdentity {
24295 key: ProviderSessionKey::SessionId,
24296 id: history.session_id.clone(),
24297 transcript_path: None,
24298 };
24299 let mut expected = record("claude", "record-context-export-final-conflict");
24300 expected.state = ManagedSessionState::Live;
24301 expected.provider_session = Some(identity.clone());
24302 expected.active_session = Some(session.clone());
24303 let mut changed = expected.clone();
24304 changed.updated_at_unix_ms += 1;
24305 shared.insert_record(changed).unwrap();
24306
24307 let error = shared
24308 .commit_context_pack_for_session_record(
24309 &expected,
24310 Some(&identity),
24311 &session,
24312 "candidate-exact",
24313 &history,
24314 pack,
24315 false,
24316 )
24317 .unwrap_err();
24318
24319 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24320 assert!(shared
24321 .context_catalog
24322 .read()
24323 .unwrap_or_else(|poisoned| poisoned.into_inner())
24324 .get(&context_id)
24325 .is_none());
24326 }
24327
24328 #[tokio::test]
24329 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24330 let root = temporary_workspace_root("session-task-binding");
24331 std::fs::create_dir_all(&root).unwrap();
24332 let state_path = root.join("node-state.json");
24333 let catalog = active_registry().unwrap();
24334 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24335 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24336 let node_id = NodeId::new("node-1").unwrap();
24337 let mut shared = NodeShared::new(
24338 handle,
24339 "fixture-token".to_owned(),
24340 node_id.clone(),
24341 vec![workspace],
24342 vec![agent("claude")],
24343 );
24344 shared.state_path = Some(state_path.clone());
24345 let record = record("claude", "sr-task");
24346 let record_id = record.record_id.clone();
24347 shared.insert_record(record).unwrap();
24348 shared.persist_state().unwrap();
24349 shared
24350 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24351 .unwrap();
24352
24353 let minted = process_request_inner(
24354 &shared,
24355 77,
24356 ClientRole::Operator,
24357 NodeRequest::SetSessionTask {
24358 record_id: record_id.clone(),
24359 expected_revision: 0,
24360 target: SessionTaskTargetV1::New,
24361 },
24362 )
24363 .await
24364 .unwrap();
24365 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24366 panic!("session task mutation returned another response");
24367 };
24368 let minted_binding = minted.task_binding.clone().unwrap();
24369 let minted_task_id = minted_binding.task_id.clone().unwrap();
24370 assert_eq!(minted_binding.revision, 1);
24371 assert!(minted_task_id.as_str().starts_with("task-"));
24372 assert_eq!(minted_task_id.as_str().len(), 29);
24373
24374 let event_count = shared.current_sequence();
24375 let repeated = process_request_inner(
24376 &shared,
24377 77,
24378 ClientRole::Operator,
24379 NodeRequest::SetSessionTask {
24380 record_id: record_id.clone(),
24381 expected_revision: 1,
24382 target: SessionTaskTargetV1::Existing {
24383 task_id: minted_task_id.clone(),
24384 },
24385 },
24386 )
24387 .await
24388 .unwrap();
24389 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24390 assert_eq!(shared.current_sequence(), event_count);
24391
24392 let stale = process_request_inner(
24393 &shared,
24394 77,
24395 ClientRole::Operator,
24396 NodeRequest::SetSessionTask {
24397 record_id: record_id.clone(),
24398 expected_revision: 0,
24399 target: SessionTaskTargetV1::Existing {
24400 task_id: minted_task_id,
24401 },
24402 },
24403 )
24404 .await
24405 .unwrap_err();
24406 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24407
24408 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24409 let assigned = process_request_inner(
24410 &shared,
24411 77,
24412 ClientRole::Operator,
24413 NodeRequest::SetSessionTask {
24414 record_id: record_id.clone(),
24415 expected_revision: 1,
24416 target: SessionTaskTargetV1::Existing {
24417 task_id: assigned_id.clone(),
24418 },
24419 },
24420 )
24421 .await
24422 .unwrap();
24423 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24424 panic!("session task assignment returned another response");
24425 };
24426 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24427 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24428
24429 let cleared = process_request_inner(
24430 &shared,
24431 77,
24432 ClientRole::Operator,
24433 NodeRequest::SetSessionTask {
24434 record_id: record_id.clone(),
24435 expected_revision: 2,
24436 target: SessionTaskTargetV1::Clear,
24437 },
24438 )
24439 .await
24440 .unwrap();
24441 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24442 panic!("session task clear returned another response");
24443 };
24444 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24445 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24446 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24447 assert_eq!(
24448 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24449 cleared.updated_at_unix_ms,
24450 );
24451
24452 let persisted = std::fs::read(&state_path).unwrap();
24453 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24454 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24455 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24456 std::fs::remove_dir_all(root).unwrap();
24457 }
24458
24459 #[test]
24460 fn session_task_capability_strips_snapshots_events_and_replies() {
24461 let mut bound = record("claude", "sr-task-projection");
24462 bound.updated_at_unix_ms = 2;
24463 bound.task_binding = Some(SessionTaskBindingV1 {
24464 revision: 1,
24465 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24466 changed_at_unix_ms: 2,
24467 });
24468 let request = NodeRequest::SetSessionTask {
24469 record_id: bound.record_id.clone(),
24470 expected_revision: 1,
24471 target: SessionTaskTargetV1::Clear,
24472 };
24473 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24474 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24475 }));
24476 assert!(!request_uses_unnegotiated_capability(
24477 &request,
24478 &baseline_capabilities().unwrap(),
24479 ));
24480 assert!(request_uses_unnegotiated_capability(&request, &[]));
24481
24482 let mut snapshot = terminal_test_shared().snapshot();
24483 snapshot.session_records.push(bound.clone());
24484 let mut reply = ResponseEnvelope {
24485 request_id: 1,
24486 result: Ok(NodeResponse::Resync {
24487 event_sequence: 1,
24488 oldest_available_sequence: 1,
24489 snapshot,
24490 events: vec![NodeEventEnvelope {
24491 sequence: 1,
24492 event: NodeEvent::SessionRecordUpserted {
24493 record: bound.clone(),
24494 },
24495 }],
24496 }),
24497 };
24498 project_response_without_session_task_binding(&mut reply);
24499 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24500 panic!("task projection changed the response kind");
24501 };
24502 assert!(snapshot.session_records[0].task_binding.is_none());
24503 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24504 panic!("task projection changed the event kind");
24505 };
24506 assert!(record.task_binding.is_none());
24507
24508 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24509 sequence: 2,
24510 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24511 });
24512 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24513 panic!("task event projection changed the event kind");
24514 };
24515 assert!(record.task_binding.is_none());
24516
24517 let mut direct = ResponseEnvelope {
24518 request_id: 2,
24519 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24520 };
24521 project_response_without_session_task_binding(&mut direct);
24522 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24523 panic!("task reply projection changed the response kind");
24524 };
24525 assert!(record.task_binding.is_none());
24526 }
24527
24528 #[test]
24529 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24530 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24531 let workspace = WorkspaceConfig::new(
24532 WorkspaceId::new("primary").unwrap(),
24533 std::env::current_dir().unwrap(),
24534 )
24535 .unwrap();
24536 let config = NodeServerConfig::new(
24537 DEFAULT_NODE_ENDPOINT,
24538 "fixture-token",
24539 NodeId::new("node-1").unwrap(),
24540 [workspace],
24541 )
24542 .unwrap();
24543 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24544 }
24545
24546 #[cfg(all(feature = "fixture", windows))]
24547 #[test]
24548 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24549 let root = temporary_workspace_root("provider-bundle-argv-hold");
24550 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24551 std::fs::create_dir_all(&root).unwrap();
24552 std::fs::create_dir_all(&outside).unwrap();
24553 let proof_path = root.join("provider.proof");
24554 let release_signal = root.join("release.signal");
24555 let config = NodeServerConfig::new(
24556 format!(
24557 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24558 std::process::id(),
24559 ),
24560 "fixture-token",
24561 NodeId::new("provider-bundle-hold-node").unwrap(),
24562 [WorkspaceConfig::new(
24563 WorkspaceId::new("primary").unwrap(),
24564 std::env::current_dir().unwrap(),
24565 )
24566 .unwrap()],
24567 )
24568 .unwrap()
24569 .with_spawn_profiles(SpawnProfileRegistry::new([
24570 crate::protocol::SpawnProfileDefaults {
24571 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24572 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24573 .unwrap(),
24574 provider: agent("kimi"),
24575 mode: SessionMode::Pty,
24576 terminal_size: gate4agent_types::TerminalSize {
24577 rows: 24,
24578 columns: 80,
24579 },
24580 prompt: None,
24581 bundle_id: None,
24582 context_id: None,
24583 environment_profile_id: None,
24584 },
24585 ])
24586 .unwrap());
24587 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24588 config,
24589 agent("kimi"),
24590 proof_path.clone(),
24591 release_signal.clone(),
24592 )
24593 .unwrap();
24594 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24595
24596 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24597 agent("kimi"),
24598 proof_path.clone(),
24599 Some(release_signal.clone()),
24600 )
24601 .unwrap();
24602 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24603 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24604 let release_wait = wrapper
24605 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24606 .unwrap();
24607 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24608 assert!(proof_write < release_wait);
24609 assert!(release_wait < fixture_ready);
24610 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24611 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24612 assert_eq!(
24613 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24614 &[
24615 proof_path.to_string_lossy().into_owned(),
24616 release_signal.to_string_lossy().into_owned(),
24617 ],
24618 );
24619
24620 let error = NodeServer::provider_bundle_argv_fixture_spec(
24621 agent("kimi"),
24622 proof_path,
24623 Some(outside.join("release.signal")),
24624 )
24625 .unwrap_err();
24626 assert!(matches!(
24627 error,
24628 NodeServerError::Registry(message)
24629 if message == "provider bundle argv release path must share the proof parent"
24630 ));
24631 drop(server);
24632 std::fs::remove_dir_all(root).unwrap();
24633 std::fs::remove_dir_all(outside).unwrap();
24634 }
24635
24636 #[cfg(feature = "fixture")]
24637 #[test]
24638 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24639 let root = temporary_workspace_root("context-fixture-registry");
24640 std::fs::create_dir_all(&root).unwrap();
24641 let proof_path = root.join("context.proof");
24642 let config = NodeServerConfig::new(
24643 format!(
24644 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24645 std::process::id(),
24646 ),
24647 "fixture-token",
24648 NodeId::new("context-fixture-node").unwrap(),
24649 [WorkspaceConfig::new(
24650 WorkspaceId::new("primary").unwrap(),
24651 std::env::current_dir().unwrap(),
24652 )
24653 .unwrap()],
24654 )
24655 .unwrap();
24656 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24657 let mut enabled = server
24658 .shared
24659 .enabled_providers
24660 .iter()
24661 .map(AgentId::as_str)
24662 .collect::<Vec<_>>();
24663 enabled.sort_unstable();
24664 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24665 for provider in &server.shared.enabled_providers {
24666 assert!(server
24667 .shared
24668 .provider_adapter_contracts
24669 .iter()
24670 .any(|contract| {
24671 &contract.provider == provider
24672 && contract.family == AdapterFamily::History
24673 }));
24674 }
24675 drop(server);
24676 std::fs::remove_dir_all(root).unwrap();
24677 }
24678
24679 #[cfg(feature = "fixture")]
24680 #[test]
24681 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24682 let root = temporary_workspace_root("monitoring-context-fixture");
24683 std::fs::create_dir_all(&root).unwrap();
24684 let proof_path = root.join("context.proof");
24685 let standard = NodeServer::context_pack_fixture_catalog(
24686 proof_path.clone(),
24687 None,
24688 false,
24689 ).unwrap();
24690 let monitoring = NodeServer::context_pack_fixture_catalog(
24691 proof_path.clone(),
24692 None,
24693 true,
24694 ).unwrap();
24695 let claude = AgentId::new("claude").unwrap();
24696 let standard_claude = standard.get(&claude).unwrap();
24697 let monitoring_claude = monitoring.get(&claude).unwrap();
24698 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24702 assert_eq!(
24703 monitoring_claude.capabilities.adapters.history,
24704 standard_claude.capabilities.adapters.history,
24705 );
24706 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24707 let codex = AgentId::new("codex").unwrap();
24708 let standard_codex = standard.get(&codex).unwrap();
24709 let monitoring_codex = monitoring.get(&codex).unwrap();
24710 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24711 assert_eq!(
24712 monitoring_codex.capabilities.adapters.history,
24713 standard_codex.capabilities.adapters.history,
24714 );
24715 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24716 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24717 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24718 .unwrap();
24719 for required in [
24720 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24721 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24722 "@('role', 'text')",
24723 "g4a-private-provider-session-canary",
24724 "private-provider-login-identity",
24725 "$rawProof",
24726 "$env:GATE4AGENT_HOOK_URL",
24727 "g4a-private-provider-session-canary",
24728 ] {
24729 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24730 }
24731 for provider in ["grok", "kimi"] {
24732 let provider = AgentId::new(provider).unwrap();
24733 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24734 }
24735
24736 let config = NodeServerConfig::new(
24737 format!(
24738 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24739 std::process::id(),
24740 ),
24741 "fixture-token",
24742 NodeId::new("monitoring-context-fixture-node").unwrap(),
24743 [WorkspaceConfig::new(
24744 WorkspaceId::new("primary").unwrap(),
24745 std::env::current_dir().unwrap(),
24746 )
24747 .unwrap()],
24748 )
24749 .unwrap();
24750 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24751 assert!(server.shared.fixture_semantic_hook_policy);
24752 drop(server);
24753 std::fs::remove_dir_all(root).unwrap();
24754 }
24755
24756 #[cfg(feature = "fixture")]
24757 #[test]
24758 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24759 let root = temporary_workspace_root("kimi-context-only-fixture");
24760 std::fs::create_dir_all(&root).unwrap();
24761 let proof_path = root.join("context.proof");
24762 let standard = NodeServer::context_pack_fixture_catalog(
24763 proof_path.clone(),
24764 None,
24765 false,
24766 ).unwrap();
24767 let kimi = AgentId::new("kimi").unwrap();
24768 let context_only = NodeServer::context_pack_fixture_catalog(
24769 proof_path,
24770 Some(&kimi),
24771 false,
24772 )
24773 .unwrap();
24774
24775 let codex = AgentId::new("codex").unwrap();
24776 assert_eq!(
24777 standard.get(&codex).unwrap().launch.fixed_args,
24778 context_only.get(&codex).unwrap().launch.fixed_args,
24779 );
24780 let script = context_only
24781 .get(&kimi)
24782 .unwrap()
24783 .launch
24784 .fixed_args
24785 .iter()
24786 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24787 .expect("Kimi context-only validation script is missing");
24788 for required in [
24789 "$bundleArgs.Count -ne 0",
24790 "GATE4AGENT_CONTEXT_ROOT",
24791 "context-pack.json",
24792 "$entries.Count -ne 1",
24793 "g4a-context-pack-v1",
24794 "$document.PSObject.Properties['cwd']",
24795 "'claude', 'codex', 'grok', 'kimi'",
24796 "$hasUser",
24797 "$hasAssistant",
24798 "SHA256",
24799 "context-only",
24800 ] {
24801 assert!(script.contains(required), "missing validation: {required}");
24802 }
24803 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24804 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24805 }
24806 assert!(!standard
24807 .get(&kimi)
24808 .unwrap()
24809 .launch
24810 .fixed_args
24811 .iter()
24812 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24813
24814 std::fs::remove_dir_all(root).unwrap();
24815 }
24816
24817 #[cfg(all(feature = "fixture", windows))]
24818 #[tokio::test]
24819 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24820 let root = temporary_workspace_root("kimi-context-only-spawn");
24821 let workspace_root = root.join("workspace");
24822 std::fs::create_dir_all(&workspace_root).unwrap();
24823 let materialization_root = root.join("private-materializations");
24824 let proof_path = root.join("context.proof");
24825 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24826 let workspace_id = WorkspaceId::new("primary").unwrap();
24827 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24828 let endpoint = format!(
24829 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24830 std::process::id(),
24831 unix_time_ms(),
24832 );
24833 let spawn_profiles = SpawnProfileRegistry::new([
24834 crate::protocol::SpawnProfileDefaults {
24835 profile_id: profile_id.clone(),
24836 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24837 provider: agent("kimi"),
24838 mode: SessionMode::Pty,
24839 terminal_size: gate4agent_types::TerminalSize {
24840 rows: 30,
24841 columns: 120,
24842 },
24843 prompt: None,
24844 bundle_id: None,
24845 context_id: None,
24846 environment_profile_id: None,
24847 },
24848 ])
24849 .unwrap();
24850 let mut config = NodeServerConfig::new(
24851 &endpoint,
24852 "fixture-token",
24853 node_id.clone(),
24854 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24855 )
24856 .unwrap()
24857 .with_spawn_profiles(spawn_profiles)
24858 .with_session_environment_materialization(
24859 materialization_root,
24860 Arc::new(FixtureSecretResolver {
24861 deny: Arc::new(AtomicBool::new(false)),
24862 }),
24863 )
24864 .unwrap();
24865 config.fixture_raw_pty_runtime = true;
24866 let server = NodeServer::new_context_only_proof_fixture(
24867 config,
24868 agent("kimi"),
24869 proof_path.clone(),
24870 )
24871 .unwrap();
24872 let shared = Arc::clone(&server.shared);
24873 let shutdown = server.shutdown_handle();
24874
24875 let history = HistorySessionRecord {
24876 session_id: "context-only-source-history".to_owned(),
24877 title: None,
24878 cwd: None,
24879 model: None,
24880 message_count: 2,
24881 completed_turn_count: None,
24882 total_tokens: 0,
24883 messages: vec![
24884 gate4agent_types::HistoryMessageRecord {
24885 role: gate4agent_types::HistoryMessageRole::User,
24886 text: "continue the bounded parent run through native C2".to_owned(),
24887 },
24888 gate4agent_types::HistoryMessageRecord {
24889 role: gate4agent_types::HistoryMessageRole::Assistant,
24890 text: "the exact parent context is ready for the child run".to_owned(),
24891 },
24892 ],
24893 };
24894 let pack = NodeContextPack::export(
24895 ContextPackLineageReceipt {
24896 source_node_id: node_id.clone(),
24897 source_session: SessionAddress {
24898 workspace_id: workspace_id.clone(),
24899 session: SessionKey {
24900 instance_id: AgentInstanceId(71),
24901 generation: SessionGeneration(1),
24902 },
24903 },
24904 source_provider: agent("codex"),
24905 },
24906 &history,
24907 )
24908 .unwrap();
24909 let expected_context_hash = digest(&SHA256, pack.bytes())
24910 .as_ref()
24911 .iter()
24912 .map(|byte| format!("{byte:02x}"))
24913 .collect::<String>();
24914 let context = shared
24915 .context_catalog
24916 .write()
24917 .unwrap_or_else(|poisoned| poisoned.into_inner())
24918 .insert(pack)
24919 .unwrap();
24920 let spec = SpawnSpec {
24921 target: crate::protocol::SpawnTarget {
24922 node_id,
24923 workspace_id: workspace_id.clone(),
24924 worktree_id: None,
24925 },
24926 profile_id,
24927 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24928 "target-kimi-r1",
24929 ).unwrap(),
24930 overrides: crate::protocol::SpawnOverrides {
24931 context_id: crate::protocol::SpawnOverride::Set {
24932 value: context.id.clone(),
24933 },
24934 ..crate::protocol::SpawnOverrides::default()
24935 },
24936 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24937 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24938 required_capabilities: SpawnRequiredCapabilities::default(),
24939 };
24940 let resolved = match shared.resolve_spawn_spec(&spec) {
24941 Ok(resolved) => resolved,
24942 Err(error) => panic!(
24943 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24944 error.code,
24945 error.message,
24946 ),
24947 };
24948 let resolved_context = match shared.resolve_context(&resolved) {
24949 Ok(Some(resolved_context)) => resolved_context,
24950 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24951 Err(error) => panic!(
24952 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24953 error.code,
24954 error.message,
24955 ),
24956 };
24957 assert_eq!(resolved_context, context);
24958 let runtime_policy = match shared
24959 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24960 .await
24961 {
24962 Ok(runtime_policy) => runtime_policy,
24963 Err(error) => panic!(
24964 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24965 error.code,
24966 error.message,
24967 ),
24968 };
24969 assert!(runtime_policy.raw_pty_lifecycle);
24970
24971 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24972 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24973 agent("kimi"),
24974 TransportKind::Pty,
24975 vec![EnvMutation {
24976 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24977 value: Some(OsString::from("must-remain-rejected")),
24978 }],
24979 )
24980 .unwrap();
24981 assert_eq!(
24982 launch_control
24983 .install_native_launch_environment_overlay(
24984 AgentInstanceId(72),
24985 generic_overlay,
24986 )
24987 .unwrap_err(),
24988 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24989 );
24990
24991 let diagnostic_address = SessionAddress {
24992 workspace_id: workspace_id.clone(),
24993 session: SessionKey {
24994 instance_id: AgentInstanceId(73),
24995 generation: SessionGeneration(1),
24996 },
24997 };
24998 let (diagnostic_overlay, diagnostic_materialization) = match shared
24999 .prepare_session_materialization(
25000 &diagnostic_address,
25001 &agent("kimi"),
25002 SessionMode::Pty,
25003 None,
25004 None,
25005 Some(&resolved_context),
25006 None,
25007 )
25008 {
25009 Ok(Some(prepared)) => prepared,
25010 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
25011 Err(error) => panic!(
25012 "Kimi context-only materialization rejected: exact_code={:?} message={}",
25013 error.code,
25014 error.message,
25015 ),
25016 };
25017 let diagnostic_overlay = diagnostic_overlay
25018 .expect("Kimi context-only materialization omitted its environment overlay");
25019 assert!(matches!(
25020 &diagnostic_overlay,
25021 PreparedNativeLaunchOverlay::Environment(_),
25022 ));
25023 match shared.install_prepared_launch_overlay(
25024 diagnostic_address.session.instance_id,
25025 diagnostic_overlay,
25026 ) {
25027 Ok(None) => {}
25028 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
25029 Err(error) => panic!(
25030 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
25031 error.code,
25032 error.message,
25033 ),
25034 }
25035 assert!(launch_control.clear_native_instance_launch_overlay(
25036 diagnostic_address.session.instance_id,
25037 ));
25038 drop(diagnostic_materialization);
25039 assert!(shared
25040 .materializations
25041 .lock()
25042 .unwrap_or_else(|poisoned| poisoned.into_inner())
25043 .is_empty());
25044
25045 let node_task = tokio::spawn(server.run());
25046 let ready_client = timeout(Duration::from_secs(5), async {
25047 loop {
25048 match gate4agent_node_wire::LocalNodeClient::connect(
25049 &endpoint,
25050 &shared.node_id,
25051 ClientRole::Observer,
25052 "fixture-token",
25053 )
25054 .await
25055 {
25056 Ok(client) => break client,
25057 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
25058 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
25059 }
25060 }
25061 })
25062 .await
25063 .expect("Kimi context-only Node did not become ready");
25064 assert_eq!(
25065 ready_client
25066 .hello()
25067 .snapshot
25068 .provider_runtime_statuses
25069 .iter()
25070 .find(|status| status.provider().as_str() == "kimi")
25071 .expect("Kimi runtime status is missing")
25072 .mode(),
25073 crate::protocol::ProviderRuntimeMode::RawPassthrough,
25074 );
25075 drop(ready_client);
25076
25077 let receipt = match shared.spawn_from_spec(spec).await {
25078 Ok(receipt) => receipt,
25079 Err(error) => panic!(
25080 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25081 error.code,
25082 error.message,
25083 ),
25084 };
25085 assert_eq!(receipt.provider, agent("kimi"));
25086 assert_eq!(receipt.context.as_ref(), Some(&context));
25087 assert!(receipt.context_binding_is_valid());
25088 timeout(Duration::from_secs(5), async {
25089 while !proof_path.is_file() {
25090 sleep(Duration::from_millis(10)).await;
25091 }
25092 })
25093 .await
25094 .expect("Kimi context-only fixture did not write its proof");
25095
25096 let proof = std::fs::read_to_string(&proof_path).unwrap();
25097 let lines = proof.lines().collect::<Vec<_>>();
25098 assert_eq!(lines.len(), 7);
25099 assert_eq!(lines[0], "context-only");
25100 assert!(Path::new(lines[1]).is_absolute());
25101 assert_ne!(
25102 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25103 std::fs::canonicalize(&workspace_root).unwrap(),
25104 );
25105 assert_eq!(
25106 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25107 std::fs::canonicalize(&workspace_root).unwrap(),
25108 );
25109 assert_eq!(lines[3], expected_context_hash);
25110 assert_eq!(lines[4], "g4a-context-pack-v1");
25111 assert_eq!(lines[5], "codex");
25112 assert_eq!(lines[6], "2");
25113 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25114 session.instance_id == receipt.session.session.instance_id
25115 && session.agent_id.as_str() == "kimi"
25116 }));
25117
25118 shutdown.request_shutdown().await.unwrap();
25119 timeout(Duration::from_secs(5), node_task)
25120 .await
25121 .unwrap()
25122 .unwrap()
25123 .unwrap();
25124 drop(shutdown);
25125 drop(shared);
25126 std::fs::remove_dir_all(root).unwrap();
25127 }
25128
25129 #[cfg(feature = "fixture")]
25130 #[test]
25131 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25132 let root = temporary_workspace_root("clean-exit-constructor");
25133 std::fs::create_dir_all(&root).unwrap();
25134 let config = construction_test_config("clean-exit-constructor")
25135 .with_state_path(root.join("node-state.json"))
25136 .unwrap();
25137 let server = NodeServer::new_clean_exit_fixture(
25138 config,
25139 root.clone(),
25140 root.join("started.marker"),
25141 root.join("release.signal"),
25142 )
25143 .unwrap();
25144 assert_eq!(
25145 server
25146 .shared
25147 .enabled_providers
25148 .iter()
25149 .map(AgentId::as_str)
25150 .collect::<Vec<_>>(),
25151 ["claude"],
25152 );
25153 drop(server);
25154 std::fs::remove_dir_all(root).unwrap();
25155 }
25156
25157 #[test]
25158 fn production_registry_provider_contract_manifest_is_exact() {
25159 let registry = active_registry().unwrap();
25160 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25161 assert_eq!(
25162 providers
25163 .iter()
25164 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25165 .collect::<Vec<_>>(),
25166 vec![
25167 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25168 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25169 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25170 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25171 ]
25172 );
25173 assert_eq!(
25174 adapters
25175 .iter()
25176 .map(|contract| (
25177 contract.provider.as_str(),
25178 contract.family,
25179 contract.adapter_id.as_str(),
25180 contract.revision.as_str(),
25181 ))
25182 .collect::<Vec<_>>(),
25183 vec![
25184 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25185 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25186 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25187 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25188 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25189 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25190 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25191 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25192 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25193 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25194 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25195 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25196 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25197 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25198 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25199 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25200 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25201 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25202 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25203 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25204 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25205 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25206 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25207 ]
25208 );
25209 }
25210
25211 fn construction_test_config(name: &str) -> NodeServerConfig {
25212 let workspace = WorkspaceConfig::new(
25213 WorkspaceId::new("primary").unwrap(),
25214 std::env::current_dir().unwrap(),
25215 )
25216 .unwrap();
25217 NodeServerConfig::new(
25218 format!(r"\\.\pipe\gate4agent-{name}"),
25219 "fixture-token",
25220 NodeId::new(name).unwrap(),
25221 [workspace],
25222 )
25223 .unwrap()
25224 }
25225
25226 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25227 let adapter = AdapterBinding::new(
25228 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25229 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25230 gate4agent_types::AdapterVerification::SyntheticFixture,
25231 )
25232 .unwrap();
25233 let agent_ids = ["claude", "codex", "kimi"]
25234 .into_iter()
25235 .map(|agent_id| AgentId::new(agent_id).unwrap())
25236 .collect::<Vec<_>>();
25237 let adapters = gate4agent_catalog::AdapterRegistry::new(
25238 [
25239 AdapterFamily::PtySemantic,
25240 AdapterFamily::Pipe,
25241 AdapterFamily::OneShot,
25242 AdapterFamily::Acp,
25243 AdapterFamily::Hook,
25244 AdapterFamily::ManagedHook,
25245 AdapterFamily::History,
25246 AdapterFamily::Resume,
25247 AdapterFamily::SessionOptions,
25248 AdapterFamily::CapabilityProbe,
25249 ]
25250 .into_iter()
25251 .map(|family| gate4agent_catalog::AdapterDescriptor {
25252 family,
25253 binding: adapter.clone(),
25254 agents: agent_ids.clone(),
25255 }),
25256 )
25257 .unwrap();
25258 let specs = agent_ids
25259 .iter()
25260 .map(|agent_id| {
25261 let agent_id = agent_id.as_str();
25262 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25263 spec.revision =
25264 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25265 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25266 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25267 adapter: adapter.clone(),
25268 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25269 launch_override: None,
25270 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25271 });
25272 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25273 adapter: adapter.clone(),
25274 launch_override: None,
25275 });
25276 spec.capabilities.adapters.hook = Some(adapter.clone());
25277 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25278 spec.capabilities.adapters.one_shot = Some(adapter.clone());
25279 spec.capabilities.adapters.history = Some(adapter.clone());
25280 spec.capabilities.adapters.resume = Some(adapter.clone());
25281 spec.capabilities.adapters.session_options = Some(adapter.clone());
25282 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25283 spec
25284 })
25285 .collect::<Vec<_>>();
25286 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25287 }
25288
25289 #[test]
25297 fn node_server_construction_accepts_the_production_manifest() {
25298 let registry = active_registry().unwrap();
25299 let (providers, _) = provider_contract_manifest(®istry).unwrap();
25300 assert_eq!(providers.len(), registry.iter().count());
25301 assert!(!providers.is_empty());
25302 let server = NodeServer::new_with_registry(
25303 construction_test_config("manifest-production-bound"),
25304 registry,
25305 )
25306 .unwrap();
25307 drop(server);
25308 }
25309
25310 #[test]
25311 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25312 let registry = maximum_size_provider_manifest_registry();
25313 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
25314 assert_eq!(providers.len(), 3);
25315 assert_eq!(adapters.len(), 30);
25316 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25317 let error = match NodeServer::new_with_registry(
25318 construction_test_config("manifest-handshake-overflow"),
25319 registry,
25320 ) {
25321 Ok(_) => panic!("oversized negotiated manifest was accepted"),
25322 Err(error) => error,
25323 };
25324 assert!(matches!(
25325 error,
25326 NodeServerError::ProviderContractManifest(message)
25327 if message.contains("authentication binding length")
25328 && message.contains("8192-byte limit")
25329 ));
25330 }
25331
25332 #[test]
25333 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25334 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25335 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25336 let registry = AgentRegistry::new([spec]).unwrap();
25337 assert!(matches!(
25338 provider_contract_manifest(®istry),
25339 Err(NodeServerError::ProviderContractManifest(message))
25340 if message.contains("exceeds the 128-byte limit")
25341 ));
25342 }
25343
25344 #[test]
25345 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25346 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25347 spec.revision = "invalid revision".to_owned();
25348 let registry = AgentRegistry::new([spec]).unwrap();
25349 assert!(matches!(
25350 provider_contract_manifest(®istry),
25351 Err(NodeServerError::ProviderContractManifest(message))
25352 if message.contains("bounded lowercase ASCII")
25353 ));
25354 }
25355
25356 #[test]
25357 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25358 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25359 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25360 let conflicting_binding = builtin_registry()
25361 .get_by_id("codex")
25362 .unwrap()
25363 .capabilities
25364 .adapters
25365 .one_shot
25366 .clone()
25367 .unwrap();
25368 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25369 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25370 pipe.adapter = transport_binding;
25371 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25372 let registry = AgentRegistry::new([spec]).unwrap();
25373 assert!(matches!(
25374 provider_contract_manifest(®istry),
25375 Err(NodeServerError::ProviderContractManifest(message))
25376 if message.contains("conflicting OneShot adapter bindings")
25377 ));
25378 }
25379
25380 #[test]
25381 fn public_node_failures_expose_only_fixed_categories() {
25382 let provider = failure(
25383 NodeFailureCode::BackendOperationFailed,
25384 r"provider bearer-secret failed at C:\private\session.jsonl",
25385 );
25386 assert_eq!(provider.message, "backend-operation-failed");
25387 assert!(!provider.message.contains("bearer-secret"));
25388 assert!(!provider.message.contains(r"C:\private"));
25389 let persistence = persistence_failure(io::Error::new(
25390 io::ErrorKind::Other,
25391 r"state write failed at C:\private\state-v1.json",
25392 ));
25393 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25394 assert!(!persistence.message.contains(r"C:\private"));
25395
25396 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25397 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25398 assert_eq!(
25399 generic.to_string(),
25400 "node durable state failed: durable-state-load-failed",
25401 );
25402 }
25403 }
25404
25405 #[test]
25406 fn terminal_byte_sequences_are_bounded_before_dispatch() {
25407 let sequence = b"\x1b[1;5D".to_vec();
25408 assert_eq!(
25409 terminal_bytes_action(sequence.clone()).unwrap(),
25410 InputAction::TerminalBytes(sequence),
25411 );
25412 assert_eq!(
25413 terminal_bytes_action(Vec::new()).unwrap_err().code,
25414 NodeFailureCode::InvalidRequest,
25415 );
25416 assert_eq!(
25417 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25418 .unwrap_err()
25419 .code,
25420 NodeFailureCode::InvalidRequest,
25421 );
25422 }
25423
25424 #[test]
25425 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25426 let root = temporary_workspace_root("exclusive-state-owner");
25427 std::fs::create_dir_all(&root).unwrap();
25428 let workspace = WorkspaceConfig::new(
25429 WorkspaceId::new("primary").unwrap(),
25430 &root,
25431 )
25432 .unwrap();
25433 let state_path = root.join("state-v1.json");
25434 let endpoint = format!(
25435 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25436 std::process::id(),
25437 std::time::SystemTime::now()
25438 .duration_since(std::time::UNIX_EPOCH)
25439 .unwrap()
25440 .as_nanos(),
25441 );
25442 let config = NodeServerConfig::new(
25443 endpoint,
25444 "fixture-token",
25445 NodeId::new("node-1").unwrap(),
25446 [workspace],
25447 )
25448 .unwrap()
25449 .with_state_path(&state_path)
25450 .unwrap();
25451
25452 let first = NodeServer::new(config.clone()).unwrap();
25453 let error = match NodeServer::new(config.clone()) {
25454 Ok(_) => panic!("a second node server acquired the same durable state path"),
25455 Err(error) => error,
25456 };
25457 assert!(matches!(
25458 &error,
25459 NodeServerError::DurableState(error)
25460 if error.kind() == io::ErrorKind::WouldBlock
25461 ));
25462 assert_eq!(
25463 error.to_string(),
25464 "node durable state failed: durable-state-lock-failed",
25465 );
25466 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25467 drop(first);
25468 let second = NodeServer::new(config).unwrap();
25469 drop(second);
25470 std::fs::remove_dir_all(root).unwrap();
25471 }
25472
25473 #[test]
25474 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25475 let catalog = active_registry().unwrap();
25476 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25477 let workspace_id = WorkspaceId::new("primary").unwrap();
25478 let workspace = WorkspaceConfig::new(
25479 workspace_id.clone(),
25480 std::env::current_dir().unwrap(),
25481 )
25482 .unwrap();
25483 let shared = NodeShared::new(
25484 handle,
25485 "fixture-token".to_owned(),
25486 NodeId::new("node-1").unwrap(),
25487 vec![workspace.clone()],
25488 vec![agent("claude"), agent("codex"), agent("kimi")],
25489 );
25490 let instance_id = AgentInstanceId(17);
25491 let generation = SessionGeneration(3);
25492 assert!(shared.address_for(instance_id, generation).is_none());
25493 shared.bind_session(instance_id, workspace_id.clone(), generation);
25494 let address = SessionAddress {
25495 workspace_id,
25496 session: SessionKey { instance_id, generation },
25497 };
25498 assert_eq!(
25499 shared.workspace_root(&address.workspace_id).unwrap(),
25500 workspace.canonical_root,
25501 );
25502 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25503 shared.remove_binding(&address);
25504 assert!(shared.address_for(instance_id, generation).is_none());
25505 }
25506
25507 #[test]
25508 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25509 let address = SessionAddress {
25510 workspace_id: WorkspaceId::new("primary").unwrap(),
25511 session: SessionKey {
25512 instance_id: AgentInstanceId(7),
25513 generation: SessionGeneration(2),
25514 },
25515 };
25516 let record_id = SessionRecordId::new("record-a").unwrap();
25517 let size = gate4agent_types::TerminalSize {
25518 rows: 24,
25519 columns: 80,
25520 };
25521 let address_requests = [
25522 NodeRequest::Resume {
25523 session: address.clone(),
25524 terminal_size: size,
25525 initial_prompt: None,
25526 },
25527 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25528 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25529 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25530 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25531 NodeRequest::TerminalControl {
25532 session: address.clone(),
25533 control: TerminalControl::Enter,
25534 },
25535 NodeRequest::Resize { session: address.clone(), size },
25536 NodeRequest::Interrupt { session: address.clone() },
25537 NodeRequest::Stop { session: address.clone(), force: false },
25538 NodeRequest::Remove { session: address },
25539 ];
25540 for request in &address_requests {
25541 assert!(request_requires_open_provider_ids_with(
25542 request,
25543 |_| Some(agent("third-party-agent")),
25544 |_| Some(agent("claude")),
25545 ));
25546 assert!(!request_requires_open_provider_ids_with(
25547 request,
25548 |_| Some(agent("claude")),
25549 |_| Some(agent("claude")),
25550 ));
25551 assert!(request_requires_open_provider_ids_with(
25552 request,
25553 |_| None,
25554 |_| Some(agent("claude")),
25555 ));
25556 }
25557
25558 let record_requests = [
25559 NodeRequest::RenameSessionRecord {
25560 record_id: record_id.clone(),
25561 display_name: "renamed".to_owned(),
25562 },
25563 NodeRequest::ResumeSessionRecord {
25564 record_id: record_id.clone(),
25565 terminal_size: size,
25566 initial_prompt: None,
25567 },
25568 NodeRequest::ForgetSessionRecord { record_id },
25569 ];
25570 for request in &record_requests {
25571 assert!(request_requires_open_provider_ids_with(
25572 request,
25573 |_| Some(agent("claude")),
25574 |_| Some(agent("grok")),
25575 ));
25576 assert!(!request_requires_open_provider_ids_with(
25577 request,
25578 |_| Some(agent("claude")),
25579 |_| Some(agent("kimi")),
25580 ));
25581 assert!(request_requires_open_provider_ids_with(
25582 request,
25583 |_| Some(agent("claude")),
25584 |_| None,
25585 ));
25586 }
25587
25588 assert!(request_requires_open_provider_ids_with(
25589 &NodeRequest::Spawn {
25590 workspace_id: WorkspaceId::new("primary").unwrap(),
25591 provider: agent("third-party-agent"),
25592 mode: SessionMode::Pty,
25593 terminal_size: size,
25594 initial_prompt: None,
25595 },
25596 |_| None,
25597 |_| None,
25598 ));
25599 assert!(!request_requires_open_provider_ids_with(
25600 &NodeRequest::Snapshot,
25601 |_| None,
25602 |_| None,
25603 ));
25604 assert!(request_requires_open_provider_ids_with(
25605 &NodeRequest::ForgetContextPack {
25606 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25607 },
25608 |_| None,
25609 |_| None,
25610 ));
25611 }
25612
25613 #[test]
25614 fn legacy_projection_filters_open_record_replies_and_event_history() {
25615 let catalog = active_registry().unwrap();
25616 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25617 let workspace = WorkspaceConfig::new(
25618 WorkspaceId::new("primary").unwrap(),
25619 std::env::current_dir().unwrap(),
25620 )
25621 .unwrap();
25622 let shared = NodeShared::new(
25623 handle,
25624 "fixture-token".to_owned(),
25625 NodeId::new("node-1").unwrap(),
25626 vec![workspace],
25627 vec![agent("claude"), agent("grok")],
25628 );
25629 let legacy = record("claude", "legacy-record");
25630 let open = record("grok", "open-record");
25631 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25632 record: legacy.clone(),
25633 });
25634 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25635 record: open.clone(),
25636 });
25637 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25638 record_id: legacy.record_id.clone(),
25639 });
25640 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25641 record_id: open.record_id.clone(),
25642 });
25643 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25644 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25645 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25646 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25647
25648 let mut record_reply = ResponseEnvelope {
25649 request_id: 1,
25650 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25651 };
25652 project_response_legacy_provider_ids(&shared, &mut record_reply);
25653 assert!(matches!(
25654 record_reply.result,
25655 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25656 ));
25657
25658 let mut resync = ResponseEnvelope {
25659 request_id: 2,
25660 result: Ok(NodeResponse::Resync {
25661 event_sequence: open_removed.sequence,
25662 oldest_available_sequence: 1,
25663 snapshot: shared.snapshot(),
25664 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25665 }),
25666 };
25667 project_response_legacy_provider_ids(&shared, &mut resync);
25668 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25669 panic!("legacy projection changed the resync response kind");
25670 };
25671 assert!(snapshot.session_records.iter().all(|item| {
25672 provider_id_is_legacy(&item.provider)
25673 }));
25674 assert_eq!(events.len(), 2);
25675 assert!(events.iter().all(|event| matches!(
25676 &event.event,
25677 NodeEvent::SessionRecordUpserted { record }
25678 if provider_id_is_legacy(&record.provider)
25679 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25680 }
25681
25682 #[test]
25683 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25684 let catalog = active_registry().unwrap();
25685 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25686 let workspace_id = WorkspaceId::new("primary").unwrap();
25687 let workspace = WorkspaceConfig::new(
25688 workspace_id.clone(),
25689 std::env::current_dir().unwrap(),
25690 )
25691 .unwrap();
25692 let shared = NodeShared::new(
25693 handle,
25694 "fixture-token".to_owned(),
25695 NodeId::new("node-1").unwrap(),
25696 vec![workspace],
25697 vec![agent("claude")],
25698 );
25699 for index in 0..CONTROL_SESSIONS_MAX {
25700 shared.bind_session(
25701 AgentInstanceId((index + 1) as u64),
25702 workspace_id.clone(),
25703 SessionGeneration(1),
25704 );
25705 }
25706 let error = shared.ensure_binding_capacity().unwrap_err();
25707 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25708 }
25709
25710 #[tokio::test]
25711 async fn provider_spawn_admission_rejects_before_session_mutation() {
25712 let catalog = active_registry().unwrap();
25713 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25714 let workspace = WorkspaceConfig::new(
25715 WorkspaceId::new("primary").unwrap(),
25716 std::env::current_dir().unwrap(),
25717 )
25718 .unwrap();
25719 let mut shared = NodeShared::new(
25720 handle,
25721 "fixture-token".to_owned(),
25722 NodeId::new("node-provider-admission").unwrap(),
25723 vec![workspace],
25724 vec![agent("claude")],
25725 );
25726 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25727 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25728 ])
25729 .unwrap();
25730 let terminal_size = gate4agent_types::TerminalSize {
25731 rows: 24,
25732 columns: 80,
25733 };
25734
25735 let unavailable = shared
25736 .spawn_session(
25737 WorkspaceId::new("primary").unwrap(),
25738 agent("claude"),
25739 SessionMode::Pty,
25740 terminal_size,
25741 None,
25742 )
25743 .await
25744 .unwrap_err();
25745 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25746 assert_eq!(unavailable.message, "backend-operation-failed");
25747
25748 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25749 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25750 agent("claude"),
25751 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25752 ),
25753 ])
25754 .unwrap();
25755 let semantic = shared
25756 .spawn_session(
25757 WorkspaceId::new("primary").unwrap(),
25758 agent("claude"),
25759 SessionMode::Inline,
25760 terminal_size,
25761 None,
25762 )
25763 .await
25764 .unwrap_err();
25765 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25766 assert_eq!(semantic.message, "unsupported-capability");
25767
25768 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25769 assert!(shared
25770 .session_bindings
25771 .lock()
25772 .unwrap_or_else(|poisoned| poisoned.into_inner())
25773 .is_empty());
25774 assert!(shared
25775 .session_records
25776 .lock()
25777 .unwrap_or_else(|poisoned| poisoned.into_inner())
25778 .records
25779 .is_empty());
25780 assert!(shared.handle.snapshot().sessions.is_empty());
25781 }
25782
25783 #[tokio::test]
25784 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25785 let catalog = active_registry().unwrap();
25786 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25787 let workspace_id = WorkspaceId::new("primary").unwrap();
25788 let workspace = WorkspaceConfig::new(
25789 workspace_id.clone(),
25790 std::env::current_dir().unwrap(),
25791 )
25792 .unwrap();
25793 let shared = NodeShared::new(
25794 handle,
25795 "fixture-token".to_owned(),
25796 NodeId::new("node-raw-spawn-record").unwrap(),
25797 vec![workspace],
25798 vec![agent("claude")],
25799 );
25800 let address = SessionAddress {
25801 workspace_id,
25802 session: SessionKey {
25803 instance_id: AgentInstanceId(71),
25804 generation: SessionGeneration::default(),
25805 },
25806 };
25807
25808 let record_id = shared
25809 .bind_spawn_session(
25810 &address,
25811 agent("claude"),
25812 SessionMode::Pty,
25813 ProviderRuntimePolicy::raw_pty(),
25814 SpawnRecordPolicy::Always,
25815 None,
25816 )
25817 .unwrap()
25818 .unwrap();
25819 shared
25820 .handle
25821 .dispatch(shared.prepare_command(ControlCommand::Register {
25822 instance_id: address.session.instance_id,
25823 agent_id: agent("claude"),
25824 transport: TransportKind::Pty,
25825 }))
25826 .unwrap();
25827 runtime.tick().await;
25828
25829 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25830 session.instance_id == address.session.instance_id
25831 && session.generation == address.session.generation
25832 }));
25833 let record = shared.record(&record_id).unwrap();
25834 assert_eq!(record.display_name, "claude #1");
25835 assert_eq!(record.state, ManagedSessionState::Live);
25836 assert_eq!(record.active_session.as_ref(), Some(&address));
25837 assert!(record.provider_session.is_none());
25838 let binding_record_id = shared
25839 .session_bindings
25840 .lock()
25841 .unwrap_or_else(|poisoned| poisoned.into_inner())
25842 .get(&address.session.instance_id)
25843 .and_then(|binding| binding.record_id.clone());
25844 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25845
25846 let renamed = shared
25847 .rename_session_record(&record_id, "raw review".to_owned())
25848 .unwrap();
25849 assert_eq!(renamed.record_id, record_id);
25850 assert_eq!(renamed.display_name, "raw review");
25851 shared.reconcile_managed_record(
25852 &record_id,
25853 &address,
25854 &ControlEvent {
25855 sequence: 1,
25856 command_id: None,
25857 instance_id: address.session.instance_id,
25858 generation: address.session.generation,
25859 event: ControlEventKind::Running { process_id: None },
25860 },
25861 false,
25862 false,
25863 );
25864 let running = shared.record(&record_id).unwrap();
25865 assert_eq!(running.state, ManagedSessionState::Live);
25866 assert_eq!(running.active_session.as_ref(), Some(&address));
25867 assert_eq!(running.display_name, "raw review");
25868
25869 shared.reconcile_managed_record(
25870 &record_id,
25871 &address,
25872 &ControlEvent {
25873 sequence: 2,
25874 command_id: None,
25875 instance_id: address.session.instance_id,
25876 generation: address.session.generation,
25877 event: ControlEventKind::Exited {
25878 exit_code: Some(0),
25879 forced: false,
25880 },
25881 },
25882 false,
25883 false,
25884 );
25885 let detached = shared.record(&record_id).unwrap();
25886 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25887 assert!(detached.active_session.is_none());
25888 assert_eq!(detached.display_name, "raw review");
25889 let renamed_detached = shared
25890 .rename_session_record(&record_id, "raw review detached".to_owned())
25891 .unwrap();
25892 assert_eq!(renamed_detached.record_id, record_id);
25893 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25894 assert!(renamed_detached.active_session.is_none());
25895 assert_eq!(renamed_detached.display_name, "raw review detached");
25896 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25897 }
25898
25899 #[test]
25900 fn legacy_raw_spawn_binding_remains_untracked() {
25901 let catalog = active_registry().unwrap();
25902 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25903 let workspace_id = WorkspaceId::new("primary").unwrap();
25904 let workspace = WorkspaceConfig::new(
25905 workspace_id.clone(),
25906 std::env::current_dir().unwrap(),
25907 )
25908 .unwrap();
25909 let shared = NodeShared::new(
25910 handle,
25911 "fixture-token".to_owned(),
25912 NodeId::new("node-legacy-raw-spawn").unwrap(),
25913 vec![workspace],
25914 vec![agent("claude")],
25915 );
25916 let address = SessionAddress {
25917 workspace_id,
25918 session: SessionKey {
25919 instance_id: AgentInstanceId(72),
25920 generation: SessionGeneration::default(),
25921 },
25922 };
25923
25924 let record_id = shared
25925 .bind_spawn_session(
25926 &address,
25927 agent("claude"),
25928 SessionMode::Pty,
25929 ProviderRuntimePolicy::raw_pty(),
25930 SpawnRecordPolicy::ProviderIdentityOnly,
25931 None,
25932 )
25933 .unwrap();
25934
25935 assert!(record_id.is_none());
25936 let binding_record_id = shared
25937 .session_bindings
25938 .lock()
25939 .unwrap_or_else(|poisoned| poisoned.into_inner())
25940 .get(&address.session.instance_id)
25941 .and_then(|binding| binding.record_id.clone());
25942 assert!(binding_record_id.is_none());
25943 assert!(shared.snapshot().session_records.is_empty());
25944 }
25945
25946 #[tokio::test]
25947 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25948 let catalog = active_registry().unwrap();
25949 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25950 let workspace_id = WorkspaceId::new("primary").unwrap();
25951 let workspace = WorkspaceConfig::new(
25952 workspace_id.clone(),
25953 std::env::current_dir().unwrap(),
25954 )
25955 .unwrap();
25956 let mut shared = NodeShared::new(
25957 handle,
25958 "fixture-token".to_owned(),
25959 NodeId::new("node-raw-semantic-gate").unwrap(),
25960 vec![workspace],
25961 vec![agent("claude")],
25962 );
25963 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25964 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25965 ])
25966 .unwrap();
25967 shared
25968 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25969 .unwrap();
25970 let address = SessionAddress {
25971 workspace_id,
25972 session: SessionKey {
25973 instance_id: AgentInstanceId(72),
25974 generation: SessionGeneration::default(),
25975 },
25976 };
25977 shared.bind_session_with_policy(
25978 &address,
25979 ProviderRuntimePolicy::raw_pty(),
25980 None,
25981 );
25982 shared
25983 .handle
25984 .dispatch(shared.prepare_command(ControlCommand::Register {
25985 instance_id: address.session.instance_id,
25986 agent_id: agent("claude"),
25987 transport: TransportKind::Pty,
25988 }))
25989 .unwrap();
25990 runtime.tick().await;
25991 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25992 let revision_before = shared.handle.snapshot().revision;
25993 let history_sequence_before = shared.current_sequence();
25994 let terminal_size = gate4agent_types::TerminalSize {
25995 rows: 24,
25996 columns: 80,
25997 };
25998
25999 for request in [
26000 NodeRequest::Prompt {
26001 session: address.clone(),
26002 text: "prompt".to_owned(),
26003 },
26004 NodeRequest::Paste {
26005 session: address.clone(),
26006 text: "paste".to_owned(),
26007 },
26008 NodeRequest::Resume {
26014 session: address.clone(),
26015 terminal_size,
26016 initial_prompt: Some("resume".to_owned()),
26017 },
26018 ] {
26019 let error = process_request_inner(
26020 &shared,
26021 77,
26022 ClientRole::Operator,
26023 request,
26024 )
26025 .await
26026 .unwrap_err();
26027 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
26028 }
26029
26030 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
26031 assert_eq!(shared.handle.snapshot().revision, revision_before);
26032 assert_eq!(shared.current_sequence(), history_sequence_before);
26033 let bindings = shared
26034 .session_bindings
26035 .lock()
26036 .unwrap_or_else(|poisoned| poisoned.into_inner());
26037 let binding = bindings.get(&address.session.instance_id).unwrap();
26038 assert_eq!(binding.generation, address.session.generation);
26039 assert!(binding.pending_resume.is_none());
26040 assert!(binding.record_id.is_none());
26041 }
26042
26043 #[tokio::test]
26044 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
26045 let catalog = active_registry().unwrap();
26046 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26047 let primary = WorkspaceConfig::new(
26048 WorkspaceId::new("primary").unwrap(),
26049 std::env::current_dir().unwrap(),
26050 )
26051 .unwrap();
26052 let shared = NodeShared::new(
26053 handle,
26054 "fixture-token".to_owned(),
26055 NodeId::new("node-1").unwrap(),
26056 vec![primary],
26057 vec![agent("claude")],
26058 );
26059 shared
26060 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26061 .unwrap();
26062 let before = shared.snapshot();
26063 let error = process_request_inner(
26064 &shared,
26065 77,
26066 ClientRole::Operator,
26067 NodeRequest::RegisterWorkspace {
26068 workspace_id: WorkspaceId::new("foreign").unwrap(),
26069 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
26070 },
26071 )
26072 .await
26073 .unwrap_err();
26074 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26075 assert_eq!(error.message, "invalid-request");
26076 assert_eq!(shared.snapshot().workspaces, before.workspaces);
26077 }
26078
26079 #[tokio::test]
26080 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26081 let catalog = active_registry().unwrap();
26082 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26083 let primary_id = WorkspaceId::new("primary").unwrap();
26084 let primary = WorkspaceConfig::new(
26085 primary_id.clone(),
26086 std::env::current_dir().unwrap(),
26087 )
26088 .unwrap();
26089 let shared = NodeShared::new(
26090 handle,
26091 "fixture-token".to_owned(),
26092 NodeId::new("node-1").unwrap(),
26093 vec![primary.clone()],
26094 vec![agent("claude")],
26095 );
26096 let secondary_id = WorkspaceId::new("secondary").unwrap();
26097 let secondary = shared
26098 .register_workspace(
26099 secondary_id.clone(),
26100 std::env::temp_dir().to_string_lossy().into_owned(),
26101 )
26102 .await
26103 .unwrap();
26104 assert_eq!(secondary.workspace_id, secondary_id);
26105 assert!(secondary.sessions.is_empty());
26106 assert_eq!(shared.snapshot().workspaces.len(), 2);
26107
26108 let duplicate_id = shared
26109 .register_workspace(
26110 secondary_id.clone(),
26111 primary.canonical_root().to_owned(),
26112 )
26113 .await
26114 .unwrap_err();
26115 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26116 let duplicate_root = shared
26117 .register_workspace(
26118 WorkspaceId::new("duplicate-root").unwrap(),
26119 windows_path_text(&secondary.canonical_root).to_owned(),
26120 )
26121 .await
26122 .unwrap_err();
26123 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26124
26125 let address = SessionAddress {
26126 workspace_id: secondary_id.clone(),
26127 session: SessionKey {
26128 instance_id: AgentInstanceId(41),
26129 generation: SessionGeneration(1),
26130 },
26131 };
26132 shared.bind_session(
26133 address.session.instance_id,
26134 secondary_id.clone(),
26135 address.session.generation,
26136 );
26137 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26138 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26139 shared.remove_binding(&address);
26140 shared.unregister_workspace(&secondary_id).unwrap();
26141 assert_eq!(shared.snapshot().workspaces.len(), 1);
26142
26143 let last = shared.unregister_workspace(&primary_id).unwrap_err();
26144 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26145 let history = shared
26146 .history
26147 .lock()
26148 .unwrap_or_else(|poisoned| poisoned.into_inner());
26149 assert!(matches!(
26150 &history.events[0],
26151 NodeEventEnvelope {
26152 sequence: 1,
26153 event: NodeEvent::WorkspaceAdded { workspace },
26154 } if workspace.workspace_id == secondary_id
26155 ));
26156 assert!(matches!(
26157 &history.events[1],
26158 NodeEventEnvelope {
26159 sequence: 2,
26160 event: NodeEvent::WorkspaceRemoved { workspace_id },
26161 } if workspace_id == &secondary_id
26162 ));
26163 }
26164
26165 #[tokio::test]
26166 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26167 let secondary_root = temporary_workspace_root("provider-session-reference-index");
26168 std::fs::create_dir_all(&secondary_root).unwrap();
26169 let catalog = active_registry().unwrap();
26170 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26171 let primary_id = WorkspaceId::new("primary").unwrap();
26172 let secondary_id = WorkspaceId::new("secondary").unwrap();
26173 let shared = NodeShared::new(
26174 handle,
26175 "fixture-token".to_owned(),
26176 NodeId::new("node-session-reference-index").unwrap(),
26177 vec![
26178 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26179 .unwrap(),
26180 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26181 ],
26182 vec![agent("claude"), agent("codex")],
26183 );
26184 let identity = ProviderSessionIdentity {
26185 key: gate4agent_types::ProviderSessionKey::SessionId,
26186 id: "provider-session-42".to_owned(),
26187 transcript_path: None,
26188 };
26189 let index_request = |workspace_id: WorkspaceId,
26190 provider: AgentId,
26191 identity: ProviderSessionIdentity,
26192 display_name: &str| {
26193 NodeRequest::IndexProviderSession {
26194 workspace_id,
26195 provider,
26196 identity,
26197 display_name: display_name.to_owned(),
26198 }
26199 };
26200 let request = index_request(
26201 primary_id.clone(),
26202 agent("claude"),
26203 identity.clone(),
26204 "release shepherd",
26205 );
26206 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26207 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26208 }));
26209 let observer = process_request_inner(
26210 &shared,
26211 77,
26212 ClientRole::Observer,
26213 request.clone(),
26214 )
26215 .await
26216 .unwrap_err();
26217 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26218 let uncontrolled = process_request_inner(
26219 &shared,
26220 77,
26221 ClientRole::Operator,
26222 request.clone(),
26223 )
26224 .await
26225 .unwrap_err();
26226 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26227 shared
26228 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26229 .unwrap();
26230
26231 let indexed = process_request_inner(
26232 &shared,
26233 77,
26234 ClientRole::Operator,
26235 request.clone(),
26236 )
26237 .await
26238 .unwrap();
26239 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26240 panic!("provider session reference index returned another response");
26241 };
26242 assert_eq!(record.state, ManagedSessionState::Dormant);
26243 assert_eq!(record.mode, SessionMode::Pty);
26244 assert_eq!(record.workspace_id, primary_id);
26245 assert_eq!(record.provider_session.as_ref(), Some(&identity));
26246 assert!(record.active_session.is_none());
26247 assert!(record.environment_profile.is_none());
26248 assert!(record.bundle.is_none());
26249 assert!(record.context.is_none());
26250
26251 let repeated = process_request_inner(
26252 &shared,
26253 77,
26254 ClientRole::Operator,
26255 index_request(
26256 primary_id.clone(),
26257 agent("claude"),
26258 identity.clone(),
26259 "ignored replacement",
26260 ),
26261 )
26262 .await
26263 .unwrap();
26264 assert!(matches!(
26265 repeated,
26266 NodeResponse::ProviderSessionIndexed { record: ref existing }
26267 if existing.record_id == record.record_id
26268 && existing.display_name == "release shepherd"
26269 ));
26270 let conflicting_workspace = process_request_inner(
26271 &shared,
26272 77,
26273 ClientRole::Operator,
26274 index_request(
26275 secondary_id,
26276 agent("claude"),
26277 identity.clone(),
26278 "release shepherd",
26279 ),
26280 )
26281 .await
26282 .unwrap_err();
26283 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26284 let conflicting_provider = process_request_inner(
26285 &shared,
26286 77,
26287 ClientRole::Operator,
26288 index_request(
26289 primary_id.clone(),
26290 agent("codex"),
26291 identity.clone(),
26292 "release shepherd",
26293 ),
26294 )
26295 .await
26296 .unwrap_err();
26297 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26298 let transcript_rejected = process_request_inner(
26299 &shared,
26300 77,
26301 ClientRole::Operator,
26302 index_request(
26303 primary_id,
26304 agent("claude"),
26305 ProviderSessionIdentity {
26306 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26307 ..identity
26308 },
26309 "release shepherd",
26310 ),
26311 )
26312 .await
26313 .unwrap_err();
26314 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26315 let upserts = shared
26316 .history
26317 .lock()
26318 .unwrap_or_else(|poisoned| poisoned.into_inner())
26319 .events
26320 .iter()
26321 .filter(|event| matches!(
26322 &event.event,
26323 NodeEvent::SessionRecordUpserted { record: published }
26324 if published.record_id == record.record_id
26325 ))
26326 .count();
26327 assert_eq!(upserts, 1);
26328 std::fs::remove_dir_all(secondary_root).unwrap();
26329 }
26330
26331 #[tokio::test]
26332 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26333 let fixture_root = temporary_workspace_root("standalone-independent");
26334 let target = fixture_root.join("independent");
26335 std::fs::create_dir_all(&fixture_root).unwrap();
26336 let catalog = active_registry().unwrap();
26337 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26338 let selected = WorkspaceConfig::new(
26339 WorkspaceId::new("primary").unwrap(),
26340 std::env::current_dir().unwrap(),
26341 )
26342 .unwrap();
26343 let selected_root = selected.canonical_root().to_owned();
26344 let shared = NodeShared::new(
26345 handle,
26346 "fixture-token".to_owned(),
26347 NodeId::new("node-standalone").unwrap(),
26348 vec![selected],
26349 vec![agent("claude")],
26350 );
26351 let workspace_id = WorkspaceId::new("independent").unwrap();
26352 let request = NodeRequest::CreateStandaloneWorkspace {
26353 workspace_id: workspace_id.clone(),
26354 root: opaque_windows_path(target.to_string_lossy().into_owned()),
26355 initial_branch: Some("main".to_owned()),
26356 };
26357 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26358 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26359 }));
26360 assert!(!request_uses_unnegotiated_capability(
26361 &request,
26362 &baseline_capabilities().unwrap(),
26363 ));
26364 assert!(request_uses_unnegotiated_capability(&request, &[]));
26365
26366 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26367 .await
26368 .unwrap_err();
26369 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26370 assert!(!target.exists());
26371 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26372 .await
26373 .unwrap_err();
26374 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26375 assert!(!target.exists());
26376 shared
26377 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26378 .unwrap();
26379
26380 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26381 .await
26382 .unwrap();
26383 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26384 panic!("standalone workspace creation returned another response");
26385 };
26386 assert_eq!(workspace.workspace_id, workspace_id);
26387 assert!(target.join(".git").is_dir());
26388 let branch = run_git_bounded(
26389 target.to_str().unwrap(),
26390 &["branch", "--show-current"],
26391 GIT_OUTPUT_MAX_BYTES,
26392 )
26393 .await
26394 .unwrap();
26395 assert!(branch.success);
26396 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26397 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26398 assert_eq!(worktrees.len(), 1);
26399 let listed_root = WorkspaceConfig::new(
26400 WorkspaceId::new("listed-independent").unwrap(),
26401 &worktrees[0].path,
26402 )
26403 .unwrap();
26404 assert!(platform::roots_equal(
26405 listed_root.canonical_root(),
26406 windows_path_text(&workspace.canonical_root),
26407 ));
26408 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26409 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26410 snapshot.workspace_id == workspace_id
26411 && snapshot.canonical_root == workspace.canonical_root
26412 }));
26413 assert!(shared
26414 .history
26415 .lock()
26416 .unwrap_or_else(|poisoned| poisoned.into_inner())
26417 .events
26418 .iter()
26419 .any(|event| matches!(
26420 &event.event,
26421 NodeEvent::WorkspaceAdded { workspace }
26422 if workspace.workspace_id == workspace_id
26423 )));
26424 std::fs::remove_dir_all(fixture_root).unwrap();
26425 }
26426
26427 #[tokio::test]
26428 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26429 let fixture_root = temporary_workspace_root("standalone-compensation");
26430 let created_target = fixture_root.join("created-target");
26431 let existing_target = fixture_root.join("existing-target");
26432 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26433 let invalid_state_path = invalid_state_parent.join("state.json");
26434 std::fs::create_dir_all(&existing_target).unwrap();
26435 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26436 let catalog = active_registry().unwrap();
26437 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26438 let primary = WorkspaceConfig::new(
26439 WorkspaceId::new("primary").unwrap(),
26440 std::env::current_dir().unwrap(),
26441 )
26442 .unwrap();
26443 let mut shared = NodeShared::new(
26444 handle,
26445 "fixture-token".to_owned(),
26446 NodeId::new("node-standalone-compensation").unwrap(),
26447 vec![primary],
26448 vec![agent("claude")],
26449 );
26450 shared.state_path = Some(invalid_state_path);
26451
26452 let created_recovery = shared
26453 .create_standalone_workspace(
26454 WorkspaceId::new("compensated").unwrap(),
26455 created_target.to_string_lossy().into_owned(),
26456 Some("main".to_owned()),
26457 )
26458 .await
26459 .unwrap_err();
26460 assert_eq!(
26461 created_recovery.code,
26462 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26463 );
26464 assert!(created_target.join(".git").is_dir());
26465 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26466 workspace.workspace_id.as_str() != "compensated"
26467 }));
26468
26469 let existing_recovery = shared
26470 .create_standalone_workspace(
26471 WorkspaceId::new("restored").unwrap(),
26472 existing_target.to_string_lossy().into_owned(),
26473 None,
26474 )
26475 .await
26476 .unwrap_err();
26477 assert_eq!(
26478 existing_recovery.code,
26479 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26480 );
26481 assert!(existing_target.is_dir());
26482 assert!(existing_target.join(".git").is_dir());
26483 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26484 workspace.workspace_id.as_str() != "restored"
26485 }));
26486
26487 let registered_empty = fixture_root.join("registered-empty");
26488 std::fs::create_dir(®istered_empty).unwrap();
26489 let catalog = active_registry().unwrap();
26490 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26491 let registered = WorkspaceConfig::new(
26492 WorkspaceId::new("registered-empty").unwrap(),
26493 ®istered_empty,
26494 )
26495 .unwrap();
26496 let duplicate_shared = NodeShared::new(
26497 handle,
26498 "fixture-token".to_owned(),
26499 NodeId::new("node-standalone-duplicate-root").unwrap(),
26500 vec![registered],
26501 vec![agent("claude")],
26502 );
26503 let duplicate = duplicate_shared
26504 .create_standalone_workspace(
26505 WorkspaceId::new("duplicate-root").unwrap(),
26506 registered_empty.to_string_lossy().into_owned(),
26507 None,
26508 )
26509 .await
26510 .unwrap_err();
26511 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26512 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26513 std::fs::remove_dir_all(fixture_root).unwrap();
26514 }
26515
26516 #[test]
26517 fn managed_record_id_collision_preserves_the_existing_record() {
26518 let catalog = active_registry().unwrap();
26519 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26520 let workspace = WorkspaceConfig::new(
26521 WorkspaceId::new("primary").unwrap(),
26522 std::env::current_dir().unwrap(),
26523 )
26524 .unwrap();
26525 let shared = NodeShared::new(
26526 handle,
26527 "fixture-token".to_owned(),
26528 NodeId::new("node-1").unwrap(),
26529 vec![workspace.clone()],
26530 vec![agent("claude")],
26531 );
26532 let record_id = SessionRecordId::new("sr-collision").unwrap();
26533 let original = ManagedSessionRecord {
26534 record_id: record_id.clone(),
26535 display_name: "original".to_owned(),
26536 provider: agent("claude"),
26537 mode: SessionMode::Pty,
26538 state: ManagedSessionState::Dormant,
26539 workspace_id: workspace.workspace_id,
26540 canonical_root: opaque_windows_path(workspace.canonical_root),
26541 provider_session: None,
26542 active_session: None,
26543 environment_profile: None,
26544 bundle: None,
26545 context_id: None,
26546 context: None,
26547 exported_context: None,
26548 task_binding: None,
26549 created_at_unix_ms: 1,
26550 updated_at_unix_ms: 1,
26551 last_error: None,
26552 };
26553 shared.insert_record(original.clone()).unwrap();
26554 let mut collision = original.clone();
26555 collision.display_name = "replacement".to_owned();
26556 let error = shared.insert_record(collision).unwrap_err();
26557 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26558 assert_eq!(shared.record(&record_id).unwrap(), original);
26559 }
26560
26561 #[test]
26562 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26563 let catalog = active_registry().unwrap();
26564 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26565 let workspace = WorkspaceConfig::new(
26566 WorkspaceId::new("primary").unwrap(),
26567 std::env::current_dir().unwrap(),
26568 )
26569 .unwrap();
26570 let shared = NodeShared::new(
26571 handle,
26572 "fixture-token".to_owned(),
26573 NodeId::new("node-1").unwrap(),
26574 vec![workspace.clone()],
26575 vec![agent("claude")],
26576 );
26577 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26578 shared
26579 .insert_record(ManagedSessionRecord {
26580 record_id: record_id.clone(),
26581 display_name: "runtime error".to_owned(),
26582 provider: agent("claude"),
26583 mode: SessionMode::Pty,
26584 state: ManagedSessionState::IdentityPending,
26585 workspace_id: workspace.workspace_id,
26586 canonical_root: opaque_windows_path(workspace.canonical_root),
26587 provider_session: None,
26588 active_session: None,
26589 environment_profile: None,
26590 bundle: None,
26591 context_id: None,
26592 context: None,
26593 exported_context: None,
26594 task_binding: None,
26595 created_at_unix_ms: 1,
26596 updated_at_unix_ms: 1,
26597 last_error: None,
26598 })
26599 .unwrap();
26600
26601 shared
26602 .mark_record_error(
26603 &record_id,
26604 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26605 )
26606 .unwrap();
26607 let record = shared.record(&record_id).unwrap();
26608 assert_eq!(record.state, ManagedSessionState::Unavailable);
26609 assert_eq!(
26610 record.last_error.as_deref(),
26611 Some("provider-runtime-failed"),
26612 );
26613 let snapshot = shared.snapshot();
26614 assert_eq!(snapshot.session_records, vec![record]);
26615 }
26616
26617 #[test]
26626 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26627 let catalog = active_registry().unwrap();
26628 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26629 let workspace = WorkspaceConfig::new(
26630 WorkspaceId::new("primary").unwrap(),
26631 std::env::current_dir().unwrap(),
26632 )
26633 .unwrap();
26634 let shared = NodeShared::new(
26635 handle,
26636 "fixture-token".to_owned(),
26637 NodeId::new("node-1").unwrap(),
26638 vec![workspace],
26639 vec![agent("claude")],
26640 );
26641
26642 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26643
26644 shared.record_rejected_command(
26645 CommandId(1),
26646 "agent 'claude' does not support transport Acp".to_owned(),
26647 true,
26648 );
26649 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26651
26652 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26653 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26654 assert!(reason.unsupported_transport);
26655
26656 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26658
26659 shared.record_rejected_command(
26664 CommandId(3),
26665 "backend busy".to_owned(),
26666 false,
26667 );
26668 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26669 assert!(!unclassified.unsupported_transport);
26670 }
26671
26672 #[test]
26673 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26674 let catalog = active_registry().unwrap();
26675 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26676 let workspace = WorkspaceConfig::new(
26677 WorkspaceId::new("primary").unwrap(),
26678 std::env::current_dir().unwrap(),
26679 )
26680 .unwrap();
26681 let shared = NodeShared::new(
26682 handle,
26683 "fixture-token".to_owned(),
26684 NodeId::new("node-identity-policy").unwrap(),
26685 vec![workspace.clone()],
26686 vec![agent("claude")],
26687 );
26688 let address = SessionAddress {
26689 workspace_id: workspace.workspace_id.clone(),
26690 session: SessionKey {
26691 instance_id: AgentInstanceId(90),
26692 generation: SessionGeneration(1),
26693 },
26694 };
26695 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26696 shared
26697 .insert_record(ManagedSessionRecord {
26698 record_id: record_id.clone(),
26699 display_name: "policy denied".to_owned(),
26700 provider: agent("claude"),
26701 mode: SessionMode::Pty,
26702 state: ManagedSessionState::IdentityPending,
26703 workspace_id: workspace.workspace_id,
26704 canonical_root: opaque_windows_path(workspace.canonical_root),
26705 provider_session: None,
26706 active_session: Some(address.clone()),
26707 environment_profile: None,
26708 bundle: None,
26709 context_id: None,
26710 context: None,
26711 exported_context: None,
26712 task_binding: None,
26713 created_at_unix_ms: 1,
26714 updated_at_unix_ms: 1,
26715 last_error: None,
26716 })
26717 .unwrap();
26718 let event = ControlEvent {
26719 sequence: 1,
26720 command_id: None,
26721 instance_id: address.session.instance_id,
26722 generation: address.session.generation,
26723 event: ControlEventKind::ProviderEvent {
26724 sequence: 1,
26725 source: gate4agent_types::ProviderSource {
26726 family: AdapterFamily::PtySemantic,
26727 binding: AdapterBinding::new(
26728 gate4agent_types::AdapterId::new("claude").unwrap(),
26729 "fixture/v1",
26730 gate4agent_types::AdapterVerification::SyntheticFixture,
26731 )
26732 .unwrap(),
26733 },
26734 source_sequence: 1,
26735 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26736 identity: gate4agent_types::ProviderSessionIdentity {
26737 key: gate4agent_types::ProviderSessionKey::SessionId,
26738 id: "provider-session".to_owned(),
26739 transcript_path: None,
26740 },
26741 },
26742 },
26743 };
26744
26745 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26746
26747 let record = shared.record(&record_id).unwrap();
26748 assert!(record.provider_session.is_none());
26749 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26750 assert_eq!(record.active_session.as_ref(), Some(&address));
26751 }
26752
26753 #[test]
26754 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26755 let secondary_root = temporary_workspace_root("identity-scope");
26756 std::fs::create_dir_all(&secondary_root).unwrap();
26757 let catalog = active_registry().unwrap();
26758 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26759 let primary = WorkspaceConfig::new(
26760 WorkspaceId::new("primary").unwrap(),
26761 std::env::current_dir().unwrap(),
26762 )
26763 .unwrap();
26764 let secondary = WorkspaceConfig::new(
26765 WorkspaceId::new("secondary").unwrap(),
26766 &secondary_root,
26767 )
26768 .unwrap();
26769 let shared = NodeShared::new(
26770 handle,
26771 "fixture-token".to_owned(),
26772 NodeId::new("node-1").unwrap(),
26773 vec![primary.clone(), secondary.clone()],
26774 vec![agent("claude")],
26775 );
26776 let identity = gate4agent_types::ProviderSessionIdentity {
26777 key: gate4agent_types::ProviderSessionKey::SessionId,
26778 id: "provider-session".to_owned(),
26779 transcript_path: None,
26780 };
26781 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26782 shared
26783 .insert_record(ManagedSessionRecord {
26784 record_id: dormant_id.clone(),
26785 display_name: "dormant".to_owned(),
26786 provider: agent("claude"),
26787 mode: SessionMode::Pty,
26788 state: ManagedSessionState::Dormant,
26789 workspace_id: primary.workspace_id,
26790 canonical_root: opaque_windows_path(primary.canonical_root),
26791 provider_session: Some(identity.clone()),
26792 active_session: None,
26793 environment_profile: None,
26794 bundle: None,
26795 context_id: None,
26796 context: None,
26797 exported_context: None,
26798 task_binding: None,
26799 created_at_unix_ms: 1,
26800 updated_at_unix_ms: 1,
26801 last_error: None,
26802 })
26803 .unwrap();
26804 let address = SessionAddress {
26805 workspace_id: secondary.workspace_id.clone(),
26806 session: SessionKey {
26807 instance_id: AgentInstanceId(91),
26808 generation: SessionGeneration(1),
26809 },
26810 };
26811 let current_id = SessionRecordId::new("sr-current").unwrap();
26812 shared
26813 .insert_record(ManagedSessionRecord {
26814 record_id: current_id.clone(),
26815 display_name: "current".to_owned(),
26816 provider: agent("claude"),
26817 mode: SessionMode::Pty,
26818 state: ManagedSessionState::IdentityPending,
26819 workspace_id: secondary.workspace_id,
26820 canonical_root: opaque_windows_path(secondary.canonical_root),
26821 provider_session: None,
26822 active_session: Some(address.clone()),
26823 environment_profile: None,
26824 bundle: None,
26825 context_id: None,
26826 context: None,
26827 exported_context: None,
26828 task_binding: None,
26829 created_at_unix_ms: 2,
26830 updated_at_unix_ms: 2,
26831 last_error: None,
26832 })
26833 .unwrap();
26834 shared.bind_managed_session(
26835 &address,
26836 current_id.clone(),
26837 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26838 None,
26839 );
26840 let event = ControlEvent {
26841 sequence: 1,
26842 command_id: None,
26843 instance_id: address.session.instance_id,
26844 generation: address.session.generation,
26845 event: ControlEventKind::ProviderEvent {
26846 sequence: 1,
26847 source: gate4agent_types::ProviderSource {
26848 family: gate4agent_types::AdapterFamily::PtySemantic,
26849 binding: gate4agent_types::AdapterBinding::new(
26850 gate4agent_types::AdapterId::new("claude").unwrap(),
26851 "fixture/v1",
26852 gate4agent_types::AdapterVerification::SyntheticFixture,
26853 )
26854 .unwrap(),
26855 },
26856 source_sequence: 1,
26857 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26858 identity,
26859 },
26860 },
26861 };
26862 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26863
26864 let binding = shared
26865 .session_bindings
26866 .lock()
26867 .unwrap_or_else(|poisoned| poisoned.into_inner())
26868 .get(&address.session.instance_id)
26869 .cloned()
26870 .unwrap();
26871 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26872 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26873 let current = shared.record(¤t_id).unwrap();
26874 assert_eq!(current.state, ManagedSessionState::Unavailable);
26875 assert_eq!(current.active_session.as_ref(), Some(&address));
26876 assert_eq!(
26877 current.last_error.as_deref(),
26878 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26879 );
26880 std::fs::remove_dir_all(secondary_root).unwrap();
26881 }
26882
26883 #[tokio::test]
26884 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26885 let root = temporary_workspace_root("semantic-provider-session");
26886 std::fs::create_dir_all(&root).unwrap();
26887 let state_path = root.join("state-v1.json");
26888 let catalog = active_registry().unwrap();
26889 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26890 let workspace = WorkspaceConfig::new(
26891 WorkspaceId::new("primary").unwrap(),
26892 &root,
26893 )
26894 .unwrap();
26895 let node_id = NodeId::new("node-semantic-identity").unwrap();
26896 let shared = NodeShared::new_with_incarnation(
26897 handle,
26898 "fixture-token".to_owned(),
26899 node_id.clone(),
26900 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26901 vec![workspace.clone()],
26902 vec![agent("claude")],
26903 ProviderRuntimeStatuses::default(),
26904 None,
26905 Vec::new(),
26906 Vec::new(),
26907 SpawnProfileRegistry::default(),
26908 None,
26909 None,
26910 None,
26911 Some(state_path.clone()),
26912 Vec::new(),
26913 Vec::new(),
26914 Vec::new(),
26915 Vec::new(),
26916 None,
26917 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26918 );
26919 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26920 key: gate4agent_types::ProviderSessionKey::SessionId,
26921 id: "provider-session".to_owned(),
26922 transcript_path: None,
26923 };
26924 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26925 shared
26926 .insert_record(ManagedSessionRecord {
26927 record_id: durable_id.clone(),
26928 display_name: "durable".to_owned(),
26929 provider: agent("claude"),
26930 mode: SessionMode::Pty,
26931 state: ManagedSessionState::Dormant,
26932 workspace_id: workspace.workspace_id.clone(),
26933 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26934 provider_session: Some(persisted_identity.clone()),
26935 active_session: None,
26936 environment_profile: None,
26937 bundle: None,
26938 context_id: None,
26939 context: None,
26940 exported_context: None,
26941 task_binding: None,
26942 created_at_unix_ms: 1,
26943 updated_at_unix_ms: 1,
26944 last_error: None,
26945 })
26946 .unwrap();
26947 let address = SessionAddress {
26948 workspace_id: workspace.workspace_id.clone(),
26949 session: SessionKey {
26950 instance_id: AgentInstanceId(92),
26951 generation: SessionGeneration(2),
26952 },
26953 };
26954 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26955 shared
26956 .insert_record(ManagedSessionRecord {
26957 record_id: pending_id.clone(),
26958 display_name: "pending".to_owned(),
26959 provider: agent("claude"),
26960 mode: SessionMode::Pty,
26961 state: ManagedSessionState::IdentityPending,
26962 workspace_id: workspace.workspace_id.clone(),
26963 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26964 provider_session: None,
26965 active_session: Some(address.clone()),
26966 environment_profile: None,
26967 bundle: None,
26968 context_id: None,
26969 context: None,
26970 exported_context: None,
26971 task_binding: None,
26972 created_at_unix_ms: 2,
26973 updated_at_unix_ms: 2,
26974 last_error: None,
26975 })
26976 .unwrap();
26977 shared.bind_managed_session(
26978 &address,
26979 pending_id.clone(),
26980 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26981 None,
26982 );
26983 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26984 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26985 ..persisted_identity
26986 };
26987 let event = ControlEvent {
26988 sequence: 1,
26989 command_id: None,
26990 instance_id: address.session.instance_id,
26991 generation: address.session.generation,
26992 event: ControlEventKind::ProviderEvent {
26993 sequence: 1,
26994 source: gate4agent_types::ProviderSource {
26995 family: gate4agent_types::AdapterFamily::PtySemantic,
26996 binding: gate4agent_types::AdapterBinding::new(
26997 gate4agent_types::AdapterId::new("claude").unwrap(),
26998 "fixture/v1",
26999 gate4agent_types::AdapterVerification::SyntheticFixture,
27000 )
27001 .unwrap(),
27002 },
27003 source_sequence: 1,
27004 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
27005 identity: observed_identity.clone(),
27006 },
27007 },
27008 };
27009 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
27010
27011 let snapshot = shared.snapshot();
27012 assert_eq!(snapshot.session_records.len(), 1);
27013 let live = &snapshot.session_records[0];
27014 assert_eq!(live.record_id, durable_id);
27015 assert_eq!(live.state, ManagedSessionState::Live);
27016 assert_eq!(live.active_session.as_ref(), Some(&address));
27017 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
27018 let binding = shared
27019 .session_bindings
27020 .lock()
27021 .unwrap_or_else(|poisoned| poisoned.into_inner())
27022 .get(&address.session.instance_id)
27023 .cloned()
27024 .unwrap();
27025 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
27026
27027 let mut duplicate = live.clone();
27028 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
27029 duplicate.display_name = "duplicate".to_owned();
27030 duplicate.state = ManagedSessionState::Dormant;
27031 duplicate.active_session = None;
27032 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
27033 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
27034 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
27035 assert_eq!(shared.snapshot().session_records.len(), 1);
27036
27037 let terminal_size = gate4agent_types::TerminalSize {
27038 rows: 24,
27039 columns: 80,
27040 };
27041 let (first_busy, second_busy) = tokio::join!(
27042 shared.resume_session_record(&durable_id, terminal_size, None),
27043 shared.resume_session_record(&durable_id, terminal_size, None),
27044 );
27045 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
27046 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
27047 assert_eq!(busy.message, "session-record-busy");
27048 }
27049
27050 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
27051 assert_eq!(loaded.records.len(), 1);
27052 assert_eq!(loaded.records[0].record_id, durable_id);
27053 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
27054 assert_eq!(
27055 loaded.records[0]
27056 .provider_session
27057 .as_ref()
27058 .unwrap()
27059 .transcript_path,
27060 None,
27061 );
27062 let catalog = active_registry().unwrap();
27063 let (reloaded_handle, _reloaded_runtime) =
27064 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27065 let reloaded = NodeShared::new_with_incarnation(
27066 reloaded_handle,
27067 "fixture-token".to_owned(),
27068 node_id,
27069 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
27070 vec![workspace],
27071 vec![agent("claude")],
27072 ProviderRuntimeStatuses::default(),
27073 None,
27074 Vec::new(),
27075 Vec::new(),
27076 SpawnProfileRegistry::default(),
27077 None,
27078 None,
27079 None,
27080 None,
27081 loaded.records,
27082 loaded.managed_worktrees,
27083 loaded.managed_worktree_tombstones,
27084 loaded.materializations,
27085 loaded.warning,
27086 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27087 );
27088 assert_eq!(reloaded.snapshot().session_records.len(), 1);
27089 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27090 std::fs::remove_dir_all(root).unwrap();
27091 }
27092
27093 #[tokio::test]
27094 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27095 let secondary_root = temporary_workspace_root("workspace-restore");
27096 let other_root = temporary_workspace_root("workspace-conflict");
27097 std::fs::create_dir_all(&secondary_root).unwrap();
27098 std::fs::create_dir_all(&other_root).unwrap();
27099 let catalog = active_registry().unwrap();
27100 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27101 let primary = WorkspaceConfig::new(
27102 WorkspaceId::new("primary").unwrap(),
27103 std::env::current_dir().unwrap(),
27104 )
27105 .unwrap();
27106 let secondary = WorkspaceConfig::new(
27107 WorkspaceId::new("secondary").unwrap(),
27108 &secondary_root,
27109 )
27110 .unwrap();
27111 let shared = NodeShared::new(
27112 handle,
27113 "fixture-token".to_owned(),
27114 NodeId::new("node-1").unwrap(),
27115 vec![primary, secondary.clone()],
27116 vec![agent("claude")],
27117 );
27118 let record_id = SessionRecordId::new("sr-workspace").unwrap();
27119 shared
27120 .insert_record(ManagedSessionRecord {
27121 record_id: record_id.clone(),
27122 display_name: "workspace record".to_owned(),
27123 provider: agent("claude"),
27124 mode: SessionMode::Pty,
27125 state: ManagedSessionState::Dormant,
27126 workspace_id: secondary.workspace_id.clone(),
27127 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27128 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27129 key: gate4agent_types::ProviderSessionKey::SessionId,
27130 id: "provider-session".to_owned(),
27131 transcript_path: None,
27132 }),
27133 active_session: None,
27134 environment_profile: None,
27135 bundle: None,
27136 context_id: None,
27137 context: None,
27138 exported_context: None,
27139 task_binding: None,
27140 created_at_unix_ms: 1,
27141 updated_at_unix_ms: 1,
27142 last_error: None,
27143 })
27144 .unwrap();
27145 shared.unregister_workspace(&secondary.workspace_id).unwrap();
27146 let unavailable = shared.record(&record_id).unwrap();
27147 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27148 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27149
27150 let conflict = shared
27151 .register_workspace(
27152 secondary.workspace_id.clone(),
27153 other_root.to_string_lossy().into_owned(),
27154 )
27155 .await
27156 .unwrap_err();
27157 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27158 shared
27159 .register_workspace(
27160 secondary.workspace_id,
27161 secondary_root.to_string_lossy().into_owned(),
27162 )
27163 .await
27164 .unwrap();
27165 let restored = shared.record(&record_id).unwrap();
27166 assert_eq!(restored.state, ManagedSessionState::Dormant);
27167 assert!(restored.last_error.is_none());
27168 std::fs::remove_dir_all(secondary_root).unwrap();
27169 std::fs::remove_dir_all(other_root).unwrap();
27170 }
27171
27172 #[test]
27173 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27174 let error = validate_workspace_request_root(
27175 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27176 )
27177 .unwrap_err();
27178 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27179 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27180 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27181 }
27182
27183 #[test]
27184 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27185 let catalog = active_registry().unwrap();
27186 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27187 let workspace = WorkspaceConfig::new(
27188 WorkspaceId::new("primary").unwrap(),
27189 std::env::current_dir().unwrap(),
27190 )
27191 .unwrap();
27192 let shared = NodeShared::new(
27193 handle,
27194 "fixture-token".to_owned(),
27195 NodeId::new("node-1").unwrap(),
27196 vec![workspace],
27197 vec![agent("claude")],
27198 );
27199 let publishers = 4_u64;
27200 let events_per_publisher = 256_u64;
27201 let start = std::sync::Barrier::new(publishers as usize + 1);
27202 let finished = AtomicU64::new(0);
27203
27204 std::thread::scope(|scope| {
27205 for _ in 0..publishers {
27206 scope.spawn(|| {
27207 start.wait();
27208 for _ in 0..events_per_publisher {
27209 shared.publish(NodeEvent::ControllerChanged { controller: None });
27210 }
27211 finished.fetch_add(1, Ordering::Release);
27212 });
27213 }
27214 start.wait();
27215 while finished.load(Ordering::Acquire) != publishers {
27216 assert_resync_history_is_linearized(shared.resync(0));
27217 std::thread::yield_now();
27218 }
27219 });
27220 let response = shared.resync(0);
27221 assert_resync_history_is_linearized(response.clone());
27222 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27223 unreachable!("resync helper returned another response");
27224 };
27225 assert_eq!(event_sequence, publishers * events_per_publisher);
27226 assert_eq!(events.len() as u64, event_sequence);
27227 }
27228
27229 fn assert_resync_history_is_linearized(response: NodeResponse) {
27230 let NodeResponse::Resync { event_sequence, events, .. } = response else {
27231 unreachable!("resync helper returned another response");
27232 };
27233 assert!(events.windows(2).all(|pair| {
27234 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27235 }));
27236 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27237 }
27238
27239 #[test]
27240 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27241 assert_eq!(
27242 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27243 r"C:\repo\worktree",
27244 );
27245 assert_eq!(
27246 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27247 r"\\server\share\repo",
27248 );
27249 assert!(validate_workspace_root(
27250 &WorkspaceId::new("network").unwrap(),
27251 r"\\server\share\repo",
27252 )
27253 .is_err());
27254 let workspace = WorkspaceConfig::new(
27255 WorkspaceId::new("primary").unwrap(),
27256 std::env::current_dir().unwrap(),
27257 )
27258 .unwrap();
27259 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27260 }
27261
27262 #[test]
27263 fn resume_rebinds_only_on_the_expected_authorized_generation() {
27264 let catalog = active_registry().unwrap();
27265 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27266 let workspace_id = WorkspaceId::new("primary").unwrap();
27267 let workspace = WorkspaceConfig::new(
27268 workspace_id.clone(),
27269 std::env::current_dir().unwrap(),
27270 )
27271 .unwrap();
27272 let shared = NodeShared::new(
27273 handle,
27274 "fixture-token".to_owned(),
27275 NodeId::new("node-1").unwrap(),
27276 vec![workspace],
27277 vec![agent("claude")],
27278 );
27279 let instance_id = AgentInstanceId(17);
27280 let original = SessionAddress {
27281 workspace_id,
27282 session: SessionKey {
27283 instance_id,
27284 generation: SessionGeneration(3),
27285 },
27286 };
27287 shared.bind_session(
27288 instance_id,
27289 original.workspace_id.clone(),
27290 original.session.generation,
27291 );
27292 shared
27293 .arm_resume(
27294 &original,
27295 CommandId(41),
27296 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27297 )
27298 .unwrap();
27299
27300 shared.publish_control(ControlEvent {
27301 sequence: 1,
27302 command_id: None,
27303 instance_id,
27304 generation: SessionGeneration(4),
27305 event: ControlEventKind::Running { process_id: None },
27306 });
27307 assert_eq!(
27308 shared.address_for(instance_id, SessionGeneration(3)),
27309 Some(original.clone()),
27310 );
27311 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27312
27313 shared.publish_control(ControlEvent {
27314 sequence: 2,
27315 command_id: Some(CommandId(42)),
27316 instance_id,
27317 generation: SessionGeneration(3),
27318 event: ControlEventKind::CommandRejected {
27319 message: "unrelated command".to_owned(),
27320 },
27321 });
27322
27323 shared.publish_control(ControlEvent {
27324 sequence: 3,
27325 command_id: None,
27326 instance_id,
27327 generation: SessionGeneration(4),
27328 event: ControlEventKind::ResumeAuthorized {
27329 session: gate4agent_types::ResumeSessionSummary {
27330 key: gate4agent_types::ProviderSessionKey::SessionId,
27331 id: "fixture-session".to_owned(),
27332 },
27333 },
27334 });
27335 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27336 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27337 assert_eq!(rebound.workspace_id, original.workspace_id);
27338 let history = shared
27339 .history
27340 .lock()
27341 .unwrap_or_else(|poisoned| poisoned.into_inner());
27342 assert!(history.events.iter().any(|envelope| matches!(
27343 &envelope.event,
27344 NodeEvent::Control { address, event }
27345 if address == &rebound
27346 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27347 )));
27348 }
27349
27350 #[test]
27351 fn all_node_pty_prompts_use_bracketed_paste_framing() {
27352 for agent in ["claude", "codex", "kimi"] {
27353 assert_eq!(
27354 prompt_framing(&AgentId::new(agent).unwrap()),
27355 PromptFraming::BracketedPaste
27356 );
27357 }
27358 }
27359
27360 fn generous_walk_deadline() -> Instant {
27361 Instant::now() + Duration::from_secs(30)
27362 }
27363
27364 #[test]
27365 fn workspace_tree_is_relative_and_skips_heavy_directories() {
27366 let root = temporary_workspace_root("tree");
27367 std::fs::create_dir_all(root.join("src")).unwrap();
27368 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27369 std::fs::create_dir_all(root.join("target/debug")).unwrap();
27370 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27371 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27372 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27373 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27379 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27380 std::fs::create_dir_all(root.join("target-notes")).unwrap();
27384 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27385
27386 let (entries, truncated, _budget) =
27387 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27388 assert!(!truncated);
27389 assert!(entries.iter().any(|entry| {
27390 entry.relative_path.as_utf8() == Some("src")
27391 && entry.kind == WorkspaceEntryKind::Directory
27392 }));
27393 assert!(entries.iter().any(|entry| {
27394 entry.relative_path.as_utf8() == Some("src/lib.rs")
27395 && entry.kind == WorkspaceEntryKind::File
27396 }));
27397 assert!(entries.iter().any(|entry| {
27398 entry.relative_path.as_utf8() == Some("target-notes")
27399 && entry.kind == WorkspaceEntryKind::Directory
27400 }));
27401 assert!(entries.iter().any(|entry| {
27402 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27403 && entry.kind == WorkspaceEntryKind::File
27404 }));
27405 assert!(entries.iter().all(|entry| {
27406 let path = entry.relative_path.as_utf8().unwrap();
27407 !path.starts_with(".git")
27408 && !(path == "target" || path.starts_with("target/"))
27409 && !(path == "target-a2" || path.starts_with("target-a2/"))
27410 && !path.starts_with("node_modules")
27411 }));
27412
27413 std::fs::remove_dir_all(&root).unwrap();
27414 }
27415
27416 #[test]
27417 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27418 let root = temporary_workspace_root("tree-entry-cap");
27419 std::fs::create_dir_all(&root).unwrap();
27420 for index in 0..20 {
27421 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27422 }
27423
27424 let (entries, truncated, budget) =
27425 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27426 assert!(truncated);
27427 assert!(budget.entry_cap_exceeded);
27428 assert!(!budget.time_budget_exceeded);
27429 assert!(entries.len() <= 5);
27430
27431 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27432 .expect("an entry-cap-truncated walk must report a truncation marker");
27433 assert!(inspection_truncation.walk_entry_cap_exceeded);
27434 assert!(!inspection_truncation.walk_time_budget_exceeded);
27435 assert!(!inspection_truncation.git_time_budget_exceeded);
27436 assert!(inspection_truncation.entries_visited >= 5);
27437
27438 std::fs::remove_dir_all(&root).unwrap();
27439 }
27440
27441 #[test]
27442 fn workspace_walk_within_budget_reports_no_truncation() {
27443 let root = temporary_workspace_root("tree-no-truncation");
27444 std::fs::create_dir_all(&root).unwrap();
27445 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27446
27447 let (_entries, truncated, budget) =
27448 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27449 assert!(!truncated);
27450 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27451
27452 std::fs::remove_dir_all(&root).unwrap();
27453 }
27454
27455 #[test]
27456 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27457 let root = temporary_workspace_root("tree-prefix-siblings");
27458 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27459 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27460 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27461 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27462
27463 let (entries, truncated, _budget) =
27464 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27465 assert!(!truncated);
27466 let paths = entries
27467 .iter()
27468 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27469 .collect::<Vec<_>>();
27470 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27471 assert_eq!(
27472 paths,
27473 vec![
27474 "gate4agent-c2".to_owned(),
27475 "gate4agent-c2-client".to_owned(),
27476 "gate4agent-c2-client/lib.rs".to_owned(),
27477 "gate4agent-c2/src".to_owned(),
27478 "gate4agent-c2/src/lib.rs".to_owned(),
27479 ],
27480 );
27481
27482 std::fs::remove_dir_all(&root).unwrap();
27483 }
27484
27485 #[test]
27486 fn git_parsers_cap_status_and_commit_results() {
27487 let mut status_output = Vec::new();
27488 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27489 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27490 }
27491 let mut snapshot = git_snapshot_for_parser();
27492 parse_git_status(&status_output, &mut snapshot);
27493 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27494 assert!(snapshot.truncated);
27495 assert_eq!(snapshot.status[0].index_status, " ");
27496 assert_eq!(snapshot.status[0].worktree_status, "M");
27497
27498 snapshot.truncated = false;
27499 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27500 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27501 .collect::<String>();
27502 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27503 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27504 assert!(snapshot.truncated);
27505 }
27506
27507 #[test]
27508 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27509 let mut snapshot = git_snapshot_for_parser();
27510 parse_git_status(
27511 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27512 &mut snapshot,
27513 );
27514
27515 assert!(!snapshot.truncated);
27516 assert_eq!(snapshot.status.len(), 3);
27517 assert_eq!(snapshot.status[0].index_status, "R");
27518 assert_eq!(snapshot.status[0].worktree_status, " ");
27519 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27520 assert_eq!(
27521 snapshot.status[0]
27522 .previous_path
27523 .as_ref()
27524 .and_then(RepositoryPath::as_utf8),
27525 Some("src/previous name.rs"),
27526 );
27527 assert_eq!(snapshot.status[1].index_status, " ");
27528 assert_eq!(snapshot.status[1].worktree_status, "C");
27529 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27530 assert_eq!(
27531 snapshot.status[1]
27532 .previous_path
27533 .as_ref()
27534 .and_then(RepositoryPath::as_utf8),
27535 Some("src/original.rs"),
27536 );
27537 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27538 assert!(snapshot.status[2].previous_path.is_none());
27539 }
27540
27541 #[test]
27542 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27543 let mut snapshot = git_snapshot_for_parser();
27544 parse_git_status(
27545 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27546 &mut snapshot,
27547 );
27548
27549 assert!(snapshot.truncated);
27550 assert_eq!(snapshot.status.len(), 1);
27551 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27552 assert!(snapshot.status[0].previous_path.is_none());
27553 }
27554
27555 #[tokio::test]
27556 async fn observer_can_browse_host_directories_without_controller() {
27557 let root = temporary_workspace_root("host-directory-observer");
27558 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27559 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27560 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27561 let workspace_id = WorkspaceId::new("primary").unwrap();
27562 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27563 let catalog = active_registry().unwrap();
27564 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27565 let shared = NodeShared::new(
27566 handle,
27567 "fixture-token".to_owned(),
27568 NodeId::new("node-1").unwrap(),
27569 vec![workspace],
27570 vec![agent("claude")],
27571 );
27572 let request = NodeRequest::BrowseHostDirectories {
27573 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27574 after: None,
27575 };
27576 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27577 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27578 }));
27579 assert!(!request_uses_unnegotiated_capability(
27580 &request,
27581 &baseline_capabilities().unwrap(),
27582 ));
27583 assert!(request_uses_unnegotiated_capability(&request, &[]));
27584
27585 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27586 .await
27587 .unwrap();
27588 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27589 panic!("host directory browse returned another response");
27590 };
27591 assert_eq!(
27592 listing
27593 .entries
27594 .iter()
27595 .map(|entry| entry.display_name.as_str())
27596 .collect::<Vec<_>>(),
27597 vec!["directory-a", "directory-b"],
27598 );
27599 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27600 assert!(!listing.incomplete);
27601 std::fs::remove_dir_all(root).unwrap();
27602 }
27603
27604 #[tokio::test]
27605 async fn observer_can_inspect_only_a_registered_workspace() {
27606 let root = temporary_workspace_root("observer");
27607 std::fs::create_dir_all(root.join("src")).unwrap();
27608 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27609 let workspace_id = WorkspaceId::new("primary").unwrap();
27610 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27611 let catalog = active_registry().unwrap();
27612 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27613 let shared = NodeShared::new(
27614 handle,
27615 "fixture-token".to_owned(),
27616 NodeId::new("node-1").unwrap(),
27617 vec![workspace],
27618 vec![agent("claude")],
27619 );
27620
27621 let response = process_request_inner(
27622 &shared,
27623 77,
27624 ClientRole::Observer,
27625 NodeRequest::InspectWorkspace {
27626 workspace_id: workspace_id.clone(),
27627 },
27628 )
27629 .await
27630 .unwrap();
27631 let NodeResponse::WorkspaceInspected { inspection } = response else {
27632 panic!("workspace inspection returned another response");
27633 };
27634 assert_eq!(inspection.workspace_id, workspace_id);
27635 assert!(inspection.entries.iter().any(|entry| {
27636 entry.relative_path.as_utf8() == Some("src/main.rs")
27637 }));
27638
27639 let unknown = process_request_inner(
27640 &shared,
27641 77,
27642 ClientRole::Observer,
27643 NodeRequest::InspectWorkspace {
27644 workspace_id: WorkspaceId::new("unknown").unwrap(),
27645 },
27646 )
27647 .await
27648 .unwrap_err();
27649 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27650 std::fs::remove_dir_all(&root).unwrap();
27651 }
27652
27653 #[test]
27654 fn context_pack_source_accepts_only_running_or_successful_exit() {
27655 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27656 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27657 exit_code: Some(0),
27658 }));
27659 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27660 exit_code: Some(1),
27661 }));
27662 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27663 exit_code: None,
27664 }));
27665 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27666 message: "provider failed".to_owned(),
27667 }));
27668 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27669 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27670 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27671 }
27672
27673 #[test]
27674 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27675 let git = git_snapshot_for_parser();
27676 let first = ContextPackRepositoryObservation {
27677 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27678 git: git.clone(),
27679 files: Vec::new(),
27680 };
27681 let second = ContextPackRepositoryObservation {
27682 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27683 git,
27684 files: Vec::new(),
27685 };
27686
27687 let error = stable_context_pack_repository(first, second).unwrap_err();
27688
27689 assert_eq!(
27690 error.code,
27691 NodeFailureCode::ContextPackMaterializationFailed,
27692 );
27693 }
27694
27695 #[test]
27696 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27697 let git = git_snapshot_for_parser();
27698 let first = ContextPackRepositoryObservation {
27699 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27700 git: git.clone(),
27701 files: vec![ContextPackRepositoryFileSource::utf8(
27702 "README.md",
27703 "first\n".to_owned(),
27704 6,
27705 )],
27706 };
27707 let second = ContextPackRepositoryObservation {
27708 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27709 git,
27710 files: vec![ContextPackRepositoryFileSource::utf8(
27711 "README.md",
27712 "other\n".to_owned(),
27713 6,
27714 )],
27715 };
27716
27717 let error = stable_context_pack_repository(first, second).unwrap_err();
27718
27719 assert_eq!(
27720 error.code,
27721 NodeFailureCode::ContextPackMaterializationFailed,
27722 );
27723 }
27724
27725 #[tokio::test]
27726 async fn native_session_catalog_zero_active_session_fixture() {
27727 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27728 let history_root = temporary_workspace_root("native-catalog-history");
27729 let outside_root = temporary_workspace_root("native-catalog-outside");
27730 std::fs::create_dir_all(&workspace_root).unwrap();
27731 std::fs::create_dir_all(&history_root).unwrap();
27732 std::fs::create_dir_all(&outside_root).unwrap();
27733 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27734 [
27735 serde_json::json!({
27736 "type": "user",
27737 "sessionId": session_id,
27738 "cwd": cwd.to_str().unwrap(),
27739 "message": { "content": "raw user text must not cross the catalog wire" }
27740 }),
27741 serde_json::json!({
27742 "type": "assistant",
27743 "sessionId": session_id,
27744 "cwd": cwd.to_str().unwrap(),
27745 "message": { "model": "claude-sonnet", "content": "raw answer" }
27746 }),
27747 serde_json::json!({
27748 "type": "ai-title",
27749 "sessionId": session_id,
27750 "aiTitle": title
27751 }),
27752 ]
27753 .into_iter()
27754 .map(|record| record.to_string())
27755 .collect::<Vec<_>>()
27756 .join("\n")
27757 };
27758 std::fs::write(
27759 history_root.join("stable-session.jsonl"),
27760 transcript("stable-session", &workspace_root, "Workspace review"),
27761 )
27762 .unwrap();
27763 std::fs::write(
27764 history_root.join("outside-session.jsonl"),
27765 transcript("outside-session", &outside_root, "Outside review"),
27766 )
27767 .unwrap();
27768 let history = NativeHistoryConfig::new(vec![
27769 NativeHistoryRoot::new(
27770 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27771 HistorySourceLayout::SingleNdjson,
27772 history_root.clone(),
27773 )
27774 .unwrap(),
27775 ])
27776 .unwrap();
27777 let catalog = active_registry().unwrap();
27778 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27779 assert!(handle.snapshot().sessions.is_empty());
27780 let workspace_id = WorkspaceId::new("primary").unwrap();
27781 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27782 let mut shared = NodeShared::new(
27783 handle,
27784 "fixture-token".to_owned(),
27785 NodeId::new("node-native-catalog").unwrap(),
27786 vec![workspace],
27787 vec![agent("claude")],
27788 );
27789 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27790 NativeSessionCatalogAuthority::new(history),
27791 )));
27792 let route = NativeSessionCatalogRoute::workspace(
27793 workspace_id.clone(),
27794 agent("claude"),
27795 );
27796 let request = NodeRequest::CatalogNativeSessions {
27797 route: route.clone(),
27798 limit: 2,
27799 };
27800 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27801 .await
27802 .unwrap_err();
27803 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27804 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27805 .await
27806 .unwrap();
27807 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27808 panic!("native catalog returned a different response");
27809 };
27810 let summary = summary.expect("node must emit native session catalog summary metadata");
27811 assert_eq!(entries.len(), 1);
27812 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27813 assert_eq!(entries[0].external_group, None);
27814 assert_eq!(entries[0].record_id, None);
27815 assert_eq!(entries[0].title, None);
27816 assert_eq!(entries[0].model, None);
27817 assert_eq!(entries[0].message_count, 0);
27818 let encoded_entries = serde_json::to_string(&entries).unwrap();
27819 assert!(!encoded_entries.contains("stable-session"));
27820 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27821 let page_request = NodeRequest::PageNativeSessions {
27822 route: route.clone(),
27823 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27824 catalog_revision: summary.catalog_revision,
27825 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27826 after_selection_id: None,
27827 limit: 2,
27828 };
27829 let observer = process_request_inner(
27830 &shared,
27831 7,
27832 ClientRole::Observer,
27833 page_request.clone(),
27834 )
27835 .await
27836 .unwrap_err();
27837 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27838 let page_response = process_request_inner(
27839 &shared,
27840 7,
27841 ClientRole::Operator,
27842 page_request.clone(),
27843 )
27844 .await
27845 .unwrap();
27846 assert!(matches!(
27847 page_response,
27848 NodeResponse::NativeSessionsPaged { page, .. }
27849 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27850 ));
27851 let stale = process_request_inner(
27852 &shared,
27853 7,
27854 ClientRole::Operator,
27855 NodeRequest::PageNativeSessions {
27856 route: route.clone(),
27857 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27858 catalog_revision: summary.catalog_revision.wrapping_add(1),
27859 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27860 after_selection_id: None,
27861 limit: 2,
27862 },
27863 )
27864 .await
27865 .unwrap_err();
27866 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27867 let selection = NativeSessionSelection {
27868 route: route.clone(),
27869 catalog_revision: summary.catalog_revision,
27870 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27871 selection_id: entries[0].selection_id.clone(),
27872 };
27873 let preview_response = process_request_inner(
27874 &shared,
27875 7,
27876 ClientRole::Operator,
27877 NodeRequest::PreviewNativeSession {
27878 selection: selection.clone(),
27879 message_limit: 1,
27880 },
27881 )
27882 .await
27883 .unwrap();
27884 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27885 panic!("native preview returned a different response");
27886 };
27887 assert_eq!(preview.messages.len(), 1);
27888 assert!(preview.message_count_exact);
27889 assert_eq!(preview.messages[0].text, "raw answer");
27890 assert!(preview.truncated);
27891
27892 let index_request = NodeRequest::IndexNativeSession {
27893 selection: selection.clone(),
27894 display_name: "Saved Claude".to_owned(),
27895 };
27896 let observer = process_request_inner(
27897 &shared,
27898 7,
27899 ClientRole::Observer,
27900 index_request.clone(),
27901 )
27902 .await
27903 .unwrap_err();
27904 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27905 let without_controller = process_request_inner(
27906 &shared,
27907 7,
27908 ClientRole::Operator,
27909 index_request.clone(),
27910 )
27911 .await
27912 .unwrap_err();
27913 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27914 let acquired = process_request_inner(
27915 &shared,
27916 7,
27917 ClientRole::Operator,
27918 NodeRequest::AcquireController { lease_ms: 5_000 },
27919 )
27920 .await
27921 .unwrap();
27922 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27923 let indexed = process_request_inner(
27924 &shared,
27925 7,
27926 ClientRole::Operator,
27927 index_request,
27928 )
27929 .await
27930 .unwrap();
27931 assert!(indexed.native_session_index_contract_is_valid());
27932 let NodeResponse::NativeSessionIndexed {
27933 selection: echoed_selection,
27934 record,
27935 } = indexed else {
27936 panic!("native session index returned a different response");
27937 };
27938 assert_eq!(echoed_selection, selection);
27939 let recataloged = process_request_inner(
27940 &shared,
27941 7,
27942 ClientRole::Operator,
27943 NodeRequest::CatalogNativeSessions {
27944 route,
27945 limit: 2,
27946 },
27947 )
27948 .await
27949 .unwrap();
27950 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27951 panic!("native catalog returned a different response after indexing");
27952 };
27953 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27954
27955 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27956 let external_catalog = process_request_inner(
27957 &shared,
27958 7,
27959 ClientRole::Operator,
27960 NodeRequest::CatalogNativeSessions {
27961 route: unregistered_route.clone(),
27962 limit: 2,
27963 },
27964 )
27965 .await
27966 .unwrap();
27967 let external_json = serde_json::to_string(&external_catalog).unwrap();
27968 assert!(!external_json.contains("outside-session"));
27969 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27970 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27971 assert!(!external_json.contains("\"session_id\""));
27972 assert!(!external_json.contains("\"cwd\""));
27973 assert!(!external_json.contains("\"path\""));
27974 let NodeResponse::NativeSessionsCataloged {
27975 route: echoed_route,
27976 entries: external_entries,
27977 summary: Some(external_summary),
27978 } = external_catalog
27979 else {
27980 panic!("unregistered native catalog returned a different response");
27981 };
27982 assert_eq!(echoed_route, unregistered_route);
27983 assert_eq!(external_entries.len(), 1);
27984 assert_eq!(external_entries[0].record_id, None);
27985 let external_group = external_entries[0]
27986 .external_group
27987 .as_ref()
27988 .expect("unregistered session must have an external group");
27989 assert_eq!(
27990 external_group.kind,
27991 gate4agent_types::NativeSessionExternalGroupKind::Project,
27992 );
27993 assert!(external_group.group_id.starts_with("external-"));
27994 assert_eq!(
27995 external_group.display_name,
27996 outside_root.file_name().unwrap().to_string_lossy(),
27997 );
27998 let external_selection = NativeSessionSelection {
27999 route: unregistered_route,
28000 catalog_revision: external_summary.catalog_revision,
28001 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
28002 selection_id: external_entries[0].selection_id.clone(),
28003 };
28004 let external_preview = process_request_inner(
28005 &shared,
28006 7,
28007 ClientRole::Operator,
28008 NodeRequest::PreviewNativeSession {
28009 selection: external_selection.clone(),
28010 message_limit: 1,
28011 },
28012 )
28013 .await
28014 .unwrap();
28015 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
28016 assert!(!external_preview_json.contains("outside-session"));
28017 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
28018 assert!(!external_preview_json.contains("\"session_id\""));
28019 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
28020 panic!("unregistered native preview returned a different response");
28021 };
28022 assert_eq!(preview.messages.len(), 1);
28023 let external_index = process_request_inner(
28024 &shared,
28025 7,
28026 ClientRole::Operator,
28027 NodeRequest::IndexNativeSession {
28028 selection: external_selection,
28029 display_name: "External Claude".to_owned(),
28030 },
28031 )
28032 .await
28033 .unwrap_err();
28034 assert_eq!(
28035 external_index.code,
28036 NodeFailureCode::WorkspaceRegistrationRequired,
28037 );
28038 let record_response = process_request_inner(
28039 &shared,
28040 7,
28041 ClientRole::Operator,
28042 NodeRequest::PreviewSessionRecord {
28043 record_id: record.record_id.clone(),
28044 message_limit: 2,
28045 },
28046 )
28047 .await
28048 .unwrap();
28049 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
28050 panic!("record preview returned a different response");
28051 };
28052 assert_eq!(record_id, record.record_id);
28053 assert_eq!(preview.messages.len(), 2);
28054 let encoded = serde_json::to_string(&preview).unwrap();
28055 assert!(!encoded.contains("stable-session"));
28056 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
28057 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
28058 panic!("record preview history summary returned a different response");
28059 };
28060 let summaries = events
28061 .iter()
28062 .filter_map(|envelope| match &envelope.event {
28063 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
28064 if record_id == &record.record_id =>
28065 {
28066 Some(*summary)
28067 }
28068 _ => None,
28069 })
28070 .collect::<Vec<_>>();
28071 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28072 let summary_json = serde_json::to_string(&summaries).unwrap();
28073 for private in [
28074 "stable-session",
28075 "raw user text must not cross the catalog wire",
28076 "raw answer",
28077 "Workspace review",
28078 "claude-sonnet",
28079 ] {
28080 assert!(!summary_json.contains(private), "{private}");
28081 }
28082 let identity = record.provider_session.clone().unwrap();
28083 assert_eq!(
28084 shared.revalidate_session_record_preview(&record, &identity),
28085 Ok(())
28086 );
28087 shared.remove_record_memory(&record.record_id);
28088 assert_eq!(
28089 shared
28090 .revalidate_session_record_preview(&record, &identity)
28091 .unwrap_err()
28092 .code,
28093 NodeFailureCode::UnknownSessionRecord
28094 );
28095 let released = process_request_inner(
28096 &shared,
28097 7,
28098 ClientRole::Operator,
28099 NodeRequest::ReleaseController,
28100 )
28101 .await
28102 .unwrap();
28103 assert!(matches!(released, NodeResponse::Controller { controller: None }));
28104 assert!(shared.controller_state().is_none());
28105 std::fs::remove_dir_all(workspace_root).unwrap();
28106 std::fs::remove_dir_all(history_root).unwrap();
28107 std::fs::remove_dir_all(outside_root).unwrap();
28108 }
28109
28110 #[tokio::test]
28111 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28112 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28113 std::fs::create_dir_all(&history_root).unwrap();
28114 let history = NativeHistoryConfig::new(vec![
28115 NativeHistoryRoot::new(
28116 gate4agent_types::AdapterId::new("claude-code").unwrap(),
28117 HistorySourceLayout::SingleNdjson,
28118 history_root.clone(),
28119 )
28120 .unwrap(),
28121 ])
28122 .unwrap();
28123 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28124 let held = catalog.lock().unwrap();
28125 let result = timeout(
28126 Duration::from_secs(1),
28127 run_native_session_catalog_operation(
28128 Arc::clone(&catalog),
28129 "native session catalog",
28130 |_| Ok::<(), std::convert::Infallible>(()),
28131 ),
28132 )
28133 .await
28134 .expect("concurrent native catalog request must not wait for the operation deadline")
28135 .unwrap_err();
28136 assert_eq!(result.code, NodeFailureCode::BackendBusy);
28137 drop(held);
28138 std::fs::remove_dir_all(history_root).unwrap();
28139 }
28140
28141 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28142 let unique = std::time::SystemTime::now()
28143 .duration_since(std::time::UNIX_EPOCH)
28144 .unwrap()
28145 .as_nanos();
28146 std::env::temp_dir().join(format!(
28147 "gate4agent-node-{label}-{}-{unique}",
28148 std::process::id(),
28149 ))
28150 }
28151
28152 fn git_snapshot_for_parser() -> GitSnapshot {
28153 GitSnapshot {
28154 is_repository: true,
28155 branch: Some("main".to_owned()),
28156 status: Vec::new(),
28157 recent_commits: Vec::new(),
28158 worktrees: Vec::new(),
28159 managed_worktree: None,
28160 truncated: false,
28161 diagnostic: None,
28162 }
28163 }
28164
28165 #[tokio::test]
28166 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28167 let root = temporary_workspace_root("managed-git-scope");
28168 std::fs::create_dir_all(&root).unwrap();
28169 run_test_git(&root, &["init"]);
28170 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28171 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28172 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28173 run_test_git(&root, &["add", "seed.txt"]);
28174 run_test_git(&root, &["commit", "-m", "seed"]);
28175 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28176
28177 let workspace_id = WorkspaceId::new("managed-a").unwrap();
28178 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28179 let catalog = active_registry().unwrap();
28180 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28181 let shared = NodeShared::new(
28182 handle,
28183 "fixture-token".to_owned(),
28184 NodeId::new("node-1").unwrap(),
28185 vec![workspace],
28186 vec![agent("claude")],
28187 );
28188 let mut lease = ManagedWorktreeLeaseRecord {
28189 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28190 source_workspace_id: WorkspaceId::new("primary").unwrap(),
28191 workspace_id: workspace_id.clone(),
28192 profile_id: WorktreeProfileId::new("default").unwrap(),
28193 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28194 target_root: root.to_string_lossy().into_owned(),
28195 branch: "gate4agent/a".to_owned(),
28196 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28197 expected_head: None,
28198 retention: ManagedWorktreeRetention::Retain,
28199 state: ManagedWorktreeLeaseState::InUse,
28200 session_holders: vec![ManagedWorktreeSessionHolder {
28201 incarnation_id: shared.incarnation_id,
28202 instance_id: AgentInstanceId(7),
28203 generation: SessionGeneration(1),
28204 }],
28205 record_holders: Vec::new(),
28206 cleanup_failure: None,
28207 created_at_unix_ms: 1,
28208 updated_at_unix_ms: 1,
28209 };
28210 *shared
28211 .managed_worktrees
28212 .lock()
28213 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28214 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28215
28216 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28217 let scope = inspection.git.managed_worktree.unwrap();
28218 assert_eq!(scope.lease_id, lease.lease_id);
28219 assert_eq!(scope.branch, "gate4agent/a");
28220 assert_eq!(scope.active_session_count, 1);
28221
28222 lease.branch = "gate4agent/b".to_owned();
28223 *shared
28224 .managed_worktrees
28225 .lock()
28226 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28227 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28228 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28229 assert!(mismatch.git.managed_worktree.is_none());
28230 std::fs::remove_dir_all(root).unwrap();
28231 }
28232
28233 #[tokio::test]
28234 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28235 let root = temporary_workspace_root("workspace-file-write-controller");
28236 std::fs::create_dir_all(root.join("src")).unwrap();
28237 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28238 let workspace_id = WorkspaceId::new("primary").unwrap();
28239 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28240 let catalog = active_registry().unwrap();
28241 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28242 let shared = NodeShared::new(
28243 handle,
28244 "fixture-token".to_owned(),
28245 NodeId::new("node-1").unwrap(),
28246 vec![workspace],
28247 vec![agent("claude")],
28248 );
28249 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28250 let expected_revision = workspace_file_revision(b"old\n");
28251 let request = NodeRequest::WriteWorkspaceFile {
28252 workspace_id: workspace_id.clone(),
28253 path: path.clone(),
28254 expected_revision: expected_revision.clone(),
28255 text: "new\n".to_owned(),
28256 };
28257 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28258 .await
28259 .unwrap_err();
28260 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28261 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28262 .await
28263 .unwrap_err();
28264 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28265 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28266 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28267 let NodeResponse::WorkspaceFileWritten { file } = response else {
28268 panic!("workspace file write returned another response");
28269 };
28270 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28271 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28272 let stale = process_request_inner(
28273 &shared,
28274 77,
28275 ClientRole::Operator,
28276 NodeRequest::WriteWorkspaceFile {
28277 workspace_id,
28278 path,
28279 expected_revision,
28280 text: "lost update\n".to_owned(),
28281 },
28282 )
28283 .await
28284 .unwrap_err();
28285 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28286 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28287 std::fs::remove_dir_all(root).unwrap();
28288 }
28289
28290 #[tokio::test]
28291 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28292 let root = temporary_workspace_root("workspace-entry-create-controller");
28293 std::fs::create_dir_all(root.join("src")).unwrap();
28294 let workspace_id = WorkspaceId::new("primary").unwrap();
28295 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28296 let catalog = active_registry().unwrap();
28297 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28298 let shared = NodeShared::new(
28299 handle,
28300 "fixture-token".to_owned(),
28301 NodeId::new("node-1").unwrap(),
28302 vec![workspace],
28303 vec![agent("claude")],
28304 );
28305 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28306 let request = NodeRequest::CreateWorkspaceFile {
28307 workspace_id: workspace_id.clone(),
28308 path: path.clone(),
28309 };
28310 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28311 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28312 }));
28313 assert!(!request_uses_unnegotiated_capability(
28314 &request,
28315 &baseline_capabilities().unwrap(),
28316 ));
28317 assert!(request_uses_unnegotiated_capability(&request, &[]));
28318 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28319 .await
28320 .unwrap_err();
28321 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28322 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28323 .await
28324 .unwrap_err();
28325 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28326 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28327
28328 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28329 .await
28330 .unwrap();
28331 let NodeResponse::WorkspaceFileCreated { file } = response else {
28332 panic!("workspace file create returned another response");
28333 };
28334 assert_eq!(file.workspace_id, workspace_id);
28335 assert_eq!(file.path, path);
28336 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28337 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28338
28339 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28340 .await
28341 .unwrap_err();
28342 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28343
28344 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28345 let response = process_request_inner(
28346 &shared,
28347 77,
28348 ClientRole::Operator,
28349 NodeRequest::CreateWorkspaceDirectory {
28350 workspace_id: workspace_id.clone(),
28351 path: directory_path.clone(),
28352 },
28353 )
28354 .await
28355 .unwrap();
28356 assert_eq!(
28357 response,
28358 NodeResponse::WorkspaceDirectoryCreated {
28359 workspace_id: workspace_id.clone(),
28360 entry: WorkspaceEntry {
28361 relative_path: directory_path,
28362 kind: WorkspaceEntryKind::Directory,
28363 },
28364 },
28365 );
28366 assert!(root.join("src/nested").is_dir());
28367
28368 let missing_parent = process_request_inner(
28369 &shared,
28370 77,
28371 ClientRole::Operator,
28372 NodeRequest::CreateWorkspaceFile {
28373 workspace_id: workspace_id.clone(),
28374 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28375 },
28376 )
28377 .await
28378 .unwrap_err();
28379 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28380 let non_directory_parent = process_request_inner(
28381 &shared,
28382 77,
28383 ClientRole::Operator,
28384 NodeRequest::CreateWorkspaceFile {
28385 workspace_id,
28386 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28387 },
28388 )
28389 .await
28390 .unwrap_err();
28391 assert_eq!(
28392 non_directory_parent.code,
28393 NodeFailureCode::RepositoryParentNotDirectory,
28394 );
28395 std::fs::remove_dir_all(root).unwrap();
28396 }
28397
28398 #[tokio::test]
28399 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28400 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28401 std::fs::create_dir_all(root.join("src")).unwrap();
28402 #[cfg(windows)]
28403 let root = std::fs::canonicalize(root).unwrap();
28404 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28405 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28406 let barrier = Arc::new(std::sync::Barrier::new(2));
28407 let done = Arc::new(AtomicBool::new(false));
28408 let task_commit_state = Arc::clone(&commit_state);
28409 let task_barrier = Arc::clone(&barrier);
28410 let task_done = Arc::clone(&done);
28411 let task_root = root.clone();
28412 let task = tokio::task::spawn_blocking(move || {
28413 task_barrier.wait();
28414 let result = create_workspace_file_on_disk(
28415 &task_root,
28416 &path,
28417 &task_commit_state,
28418 );
28419 task_done.store(true, Ordering::Release);
28420 result
28421 });
28422
28423 let error = settle_workspace_entry_create(
28424 task,
28425 Arc::clone(&commit_state),
28426 Duration::from_millis(10),
28427 "fixture deadline",
28428 "fixture task failed",
28429 )
28430 .await
28431 .unwrap_err();
28432 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28433 assert_eq!(
28434 commit_state.load(Ordering::Acquire),
28435 WORKSPACE_ENTRY_CREATE_CANCELED,
28436 );
28437
28438 barrier.wait();
28439 timeout(Duration::from_secs(1), async {
28440 while !done.load(Ordering::Acquire) {
28441 tokio::task::yield_now().await;
28442 }
28443 })
28444 .await
28445 .expect("canceled blocking create did not settle");
28446 assert!(!root.join("src/late.rs").exists());
28447 std::fs::remove_dir_all(root).unwrap();
28448 }
28449}
28450
28451#[cfg(test)]
28452mod provisional_spawn_tests {
28453 use super::*;
28454 use std::sync::Arc;
28455
28456 fn shared() -> NodeShared {
28457 let catalog = active_registry().unwrap();
28458 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28459 let workspace = WorkspaceConfig::new(
28460 WorkspaceId::new("primary").unwrap(),
28461 std::env::current_dir().unwrap(),
28462 )
28463 .unwrap()
28464 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28465 NodeShared::new(
28466 handle,
28467 "fixture-token".to_owned(),
28468 NodeId::new("node-provisional-test").unwrap(),
28469 vec![workspace],
28470 vec![AgentId::new("claude").unwrap()],
28471 )
28472 }
28473
28474 #[test]
28477 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28478 let shared = shared();
28479 let ws = WorkspaceId::new("primary").unwrap();
28480 let mut ids = Vec::with_capacity(50);
28481 for i in 1u64..=50 {
28482 let id = AgentInstanceId(i);
28483 ids.push(id);
28484 let address = SessionAddress {
28485 workspace_id: ws.clone(),
28486 session: SessionKey {
28487 instance_id: id,
28488 generation: SessionGeneration(1),
28489 },
28490 };
28491 shared.mark_spawn_provisional(address);
28492 }
28493 assert_eq!(
28494 shared
28495 .provisional_spawns
28496 .lock()
28497 .unwrap_or_else(|p| p.into_inner())
28498 .len(),
28499 50
28500 );
28501 for id in ids.iter().step_by(2) {
28502 shared.sessionize_spawn(*id);
28503 }
28504 assert_eq!(
28505 shared
28506 .provisional_spawns
28507 .lock()
28508 .unwrap_or_else(|p| p.into_inner())
28509 .len(),
28510 25
28511 );
28512 assert!(shared.take_due_provisional_spawns().is_empty());
28513 {
28514 let mut map = shared
28515 .provisional_spawns
28516 .lock()
28517 .unwrap_or_else(|p| p.into_inner());
28518 let past = Instant::now() - Duration::from_secs(91);
28519 for (_, (_, at)) in map.iter_mut() {
28520 *at = past;
28521 }
28522 }
28523 let due = shared.take_due_provisional_spawns();
28524 assert_eq!(due.len(), 25);
28525 let due_ids: std::collections::BTreeSet<_> =
28526 due.iter().map(|a| a.session.instance_id.0).collect();
28527 let expected: std::collections::BTreeSet<_> =
28528 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28529 assert_eq!(due_ids, expected);
28530 assert!(shared
28531 .provisional_spawns
28532 .lock()
28533 .unwrap_or_else(|p| p.into_inner())
28534 .is_empty());
28535 }
28536
28537 #[test]
28540 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28541 let shared = Arc::new(shared());
28542 let ws = WorkspaceId::new("primary").unwrap();
28543 let mut handles = Vec::new();
28544 for i in 1u64..=20 {
28545 let shared = Arc::clone(&shared);
28546 let ws = ws.clone();
28547 handles.push(std::thread::spawn(move || {
28548 let id = AgentInstanceId(i);
28549 let address = SessionAddress {
28550 workspace_id: ws,
28551 session: SessionKey {
28552 instance_id: id,
28553 generation: SessionGeneration(1),
28554 },
28555 };
28556 shared.mark_spawn_provisional(address);
28557 id
28558 }));
28559 }
28560 let mut ids = Vec::new();
28561 for h in handles {
28562 ids.push(h.join().unwrap());
28563 }
28564 ids.sort_by_key(|id| id.0);
28565 assert_eq!(ids.len(), 20);
28566 ids.dedup();
28567 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28568 assert_eq!(
28569 shared
28570 .provisional_spawns
28571 .lock()
28572 .unwrap_or_else(|p| p.into_inner())
28573 .len(),
28574 20
28575 );
28576 shared.sessionize_spawn(AgentInstanceId(7));
28577 let left: Vec<_> = shared
28578 .provisional_spawns
28579 .lock()
28580 .unwrap_or_else(|p| p.into_inner())
28581 .keys()
28582 .map(|id| id.0)
28583 .collect();
28584 assert!(!left.contains(&7));
28585 assert_eq!(left.len(), 19);
28586 }
28587}
28588
28589
28590#[cfg(unix)]
28591extern "C" {
28592 #[link_name = "geteuid"]
28593 fn libc_geteuid() -> u32;
28594}
28595
28596#[cfg(test)]
28597mod call_home_link_tests {
28598 use super::*;
28599
28600 #[test]
28601 fn call_home_backoff_is_capped_and_monotonic() {
28602 let mut last = Duration::ZERO;
28603 for attempt in 1..20 {
28604 let next = call_home_backoff(attempt);
28605 assert!(next >= last);
28606 assert!(next <= Duration::from_secs(8));
28607 last = next;
28608 }
28609 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28610 }
28611
28612 #[tokio::test]
28613 async fn call_home_socket_gets_keepalive() {
28614 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28615 let addr = listener.local_addr().unwrap();
28616 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28617 let client = client.unwrap();
28618 enable_call_home_keepalive(&client).unwrap();
28619 let sock = socket2::SockRef::from(&client);
28620 assert!(sock.keepalive().unwrap());
28621 #[cfg(any(target_os = "linux", target_os = "macos"))]
28622 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28623 }
28624}
28625
28626fn provider_session_exists(provider: &str, id: &str) -> bool {
28628 if provider != "claude" { return false; }
28629 let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28630 if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28631 let name = format!("{id}.jsonl");
28632 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())))
28633}
28634
28635fn overlay_with_extra_args(
28637 provider: &AgentId,
28638 mode: SessionMode,
28639 overlay: Option<PreparedNativeLaunchOverlay>,
28640 args: Vec<OsString>,
28641) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28642 let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28643 Ok(match (overlay, args) {
28644 (prepared, args) if args.is_empty() => prepared,
28645 (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28646 (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28647 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28648 provider.clone(),
28649 match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28650 Vec::new(),
28651 args,
28652 ).map_err(|_| bad())?)),
28653 })
28654}
28655
28656fn provider_error_act(workspace: &str, instance: u64, provider: &str, via: &str, act: crate::provider_error::Action) {
28658 use crate::provider_error::Action;
28659 match act {
28660 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"),
28661 Action::Limit { found } => {
28662 tracing::info!(target: "provider_retry", instance_id = instance, provider, via, "provider error: limit, no retry");
28663 if via == "acp" {
28664 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::Limit { hint: found.hint });
28665 }
28666 }
28667 Action::Escalate { found, attempts } => {
28668 tracing::warn!(target: "provider_retry", instance_id = instance, provider, via, class = found.class.as_str(), code = ?found.code, attempts, "provider error: escalated");
28669 crate::session_signals::emit(workspace, instance, provider, crate::session_signals::Signal::ProviderError { class: found.class.as_str(), code: found.code, attempts, hint: found.hint });
28670 }
28671 }
28672}
28673
28674fn trace_acp_turn(address: &SessionAddress, event: &ControlEvent) {
28677 let id = address.session.instance_id.0;
28678 let ControlEventKind::ProviderEvent { event: e, .. } = &event.event else { return };
28679 match e {
28680 ProviderEvent::TurnStarted { prompt } => tracing::info!(instance_id = id, prompt_chars = prompt.as_deref().map(str::len).unwrap_or(0), "acp turn started"),
28681 ProviderEvent::ToolStarted { name, .. } => tracing::info!(instance_id = id, tool = %name, "acp tool call started"),
28682 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"),
28683 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"),
28684 ProviderEvent::TurnInterrupted => tracing::info!(instance_id = id, "acp turn interrupted"),
28685 ProviderEvent::SessionEnded { is_error, stop_reason, .. } => tracing::info!(instance_id = id, is_error, stop_reason = ?stop_reason, "acp turn ended"),
28686 ProviderEvent::Error { message } => tracing::warn!(instance_id = id, error = %message.chars().take(200).collect::<String>(), "acp provider error"),
28687 _ => {}
28688 }
28689}