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
215tokio::task_local! {
216 static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219}
220
221fn spawn_selection(
223 options: &gate4agent_node_protocol::SpawnSessionOptions,
224) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
225 options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
226 match (&options.model, &options.effort) {
227 (None, None) => Ok(None),
228 (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
229 (Some(model), effort) => {
230 let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
231 if let Some(e) = effort {
232 sel = sel.with_value("effort", e.clone());
233 }
234 Ok(Some(sel))
235 }
236 }
237}
238
239mod browser_bridge;
240
241use gate4agent_node_core::drive_loop_profile;
242
243#[path = "bundle_delivery.rs"]
244mod bundle_delivery;
245
246#[path = "context_pack_store.rs"]
247mod context_pack_store;
248
249#[cfg(windows)]
250pub use crate::platform::DEFAULT_NODE_ENDPOINT;
251
252const NODE_EVENT_HISTORY_MAX: usize = 4_096;
253const NODE_BROADCAST_CAPACITY: usize = 1_024;
254const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
255const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
261const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
268const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
269const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
270const MAX_PREAUTH_CONNECTIONS: usize = 32;
271const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
272const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
273const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
274const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
275const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
276const PROVISIONAL_SPAWN_GRACE_MS: u64 = 30_000;
282const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
290const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
298const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
305const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
310const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
311const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
312const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
313const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
314const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
315const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
316const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
317const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
318const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
319const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
320const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
321const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
322const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
325const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
332const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
335const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
336const GIT_STATUS_MAX_ENTRIES: usize = 128;
337const GIT_COMMIT_MAX_ENTRIES: usize = 12;
338const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
339const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
340const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
341const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
342const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
343const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
344const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
345const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
346const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
347const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
348 "provider-session-identity-allocation-failed";
349const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
350const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
351const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
352const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
353const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
354const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
355const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
356 "durable-state-path-semantics-unsupported";
357
358fn opaque_windows_path(value: String) -> OpaqueHostPath {
359 OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
360}
361
362fn agent_progress_from_provider_snapshot(
363 address: SessionAddress,
364 provider: &ProviderSnapshot,
365) -> Option<SessionAgentProgress> {
366 let mut truncated = false;
367 let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
368 let mut active_tool_labels = Vec::with_capacity(
369 provider
370 .active_tools
371 .len()
372 .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
373 );
374 for tool in &provider.active_tools {
375 if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
376 truncated = true;
377 break;
378 }
379 if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
380 active_tool_labels.push(label);
381 }
382 }
383 let attention = provider
384 .interactions
385 .iter()
386 .find(|interaction| {
387 matches!(
388 interaction.status,
389 ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
390 )
391 })
392 .map(|interaction| AgentProgressAttentionV1 {
393 kind: match interaction.interaction_kind {
394 ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
395 ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
396 },
397 tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
398 });
399 let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
400 let usage = (provider.completed_turns > 0
401 || provider.usage.input_tokens > 0
402 || provider.usage.output_tokens > 0
403 || provider.usage.cache_read_tokens > 0
404 || provider.usage.cache_write_tokens > 0
405 || provider.usage.reasoning_tokens > 0)
406 .then_some(AgentProgressUsageV1 {
407 input_tokens: provider.usage.input_tokens,
408 output_tokens: provider.usage.output_tokens,
409 cache_read_tokens: provider.usage.cache_read_tokens,
410 cache_write_tokens: provider.usage.cache_write_tokens,
411 reasoning_tokens: provider.usage.reasoning_tokens,
412 });
413 let progress = AgentProgressV1 {
414 provider_sequence: provider.sequence,
415 activity: provider.activity,
416 completed_turns: provider.completed_turns,
417 usage,
418 current: AgentProgressCurrentV1::from(provider.activity),
419 active_tool_labels,
420 active_tool_count,
421 attention,
422 subagent_count,
423 last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
424 gap_count: provider.gap_count,
425 stale: provider.stale,
426 truncated,
427 };
428 let entry = SessionAgentProgress { address, progress };
429 serde_json::to_vec(&entry)
430 .ok()
431 .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
432 .map(|_| entry)
433}
434
435fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
436 match u32::try_from(count) {
437 Ok(count) => count,
438 Err(_) => {
439 *truncated = true;
440 u32::MAX
441 }
442 }
443}
444
445fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
446 if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
447 return None;
448 }
449 Some(match event {
450 ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
451 ProviderEvent::SessionIdentityObserved { .. } => {
452 AgentProgressEventKindV1::SessionIdentityObserved
453 }
454 ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
455 ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
456 ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
457 ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
458 ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
459 ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
460 ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
461 ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
462 ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
463 ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
464 ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
465 ProviderEvent::InteractionRequested { .. } => {
466 AgentProgressEventKindV1::InteractionRequested
467 }
468 ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
469 ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
470 ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
471 ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
472 ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
473 ProviderEvent::UnrecognizedNotification { .. } => {
474 AgentProgressEventKindV1::UnrecognizedNotification
475 }
476 ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
477 ProviderEvent::UserMessage { .. }
488 | ProviderEvent::Plan { .. }
489 | ProviderEvent::AvailableCommandsUpdated { .. }
490 | ProviderEvent::ModeChanged { .. }
491 | ProviderEvent::SessionInfoUpdated { .. }
492 | ProviderEvent::UsageUpdated { .. }
493 | ProviderEvent::ConfigOptionsUpdated { .. } => {
494 AgentProgressEventKindV1::UnrecognizedNotification
495 }
496 })
497}
498
499fn provider_family_is_live(family: AdapterFamily) -> bool {
512 match family {
513 AdapterFamily::PtySemantic
514 | AdapterFamily::Hook
515 | AdapterFamily::ManagedHook
516 | AdapterFamily::Pipe
517 | AdapterFamily::OneShot
518 | AdapterFamily::Acp => true,
519 AdapterFamily::History
520 | AdapterFamily::Resume
521 | AdapterFamily::SessionOptions
522 | AdapterFamily::CapabilityProbe => false,
523 }
524}
525
526fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
527 match by {
528 ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
529 ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
530 ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
531 ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
532 }
533}
534
535fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
547 match authority {
548 BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
549 BlockAuthorityV1::HostPolicy => "denied by host policy",
550 BlockAuthorityV1::HostDeadline => {
551 "denied by host policy after the operator deadline expired"
552 }
553 BlockAuthorityV1::Operator => "declined by the operator",
554 BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
555 BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
556 BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
557 BlockAuthorityV1::ProviderRefusal => "refused by the provider",
558 BlockAuthorityV1::UserRejected => "rejected by the user",
559 BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
560 BlockAuthorityV1::Unknown => "blocked; no reason was reported",
561 }
562}
563
564fn host_request_denied_block(
567 method: &str,
568 by: ProviderHostDecisionAuthority,
569 reason: Option<&str>,
570) -> (String, BlockAuthorityV1, String) {
571 let authority = block_authority(by);
572 let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
573 let (reason_text, _) =
577 truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
578 (tool_class_label(method), authority, reason_text)
579}
580
581fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
600 match kind? {
601 "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
602 "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
603 _ => None,
604 }
605}
606
607fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
619 let trimmed = text.trim();
620 let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
621 return (trimmed.to_owned(), None);
622 };
623 let tail = after_reason.trim_start();
624 let Some((reason, help)) = tail.split_once(". ") else {
625 let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
629 return (reason.to_owned(), None);
630 };
631 let help = help.trim();
632 if help.is_empty() {
633 (reason.trim().to_owned(), None)
634 } else {
635 (reason.trim().to_owned(), Some(help.to_owned()))
636 }
637}
638
639fn tool_class_from_denial_help(help: Option<&str>) -> String {
649 help.and_then(|text| text.split("add a ").nth(1))
650 .and_then(|tail| tail.split(' ').next())
651 .filter(|word| !word.is_empty())
652 .map(str::to_owned)
653 .unwrap_or_else(|| "Tool".to_owned())
654}
655
656fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
664 fn names_quota(s: &str) -> bool {
665 let lower = s.to_ascii_lowercase();
666 lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
667 }
668 vendor_code.is_some_and(names_quota) || names_quota(message)
669}
670
671fn provider_session_end_block(
687 stop_reason: &ProviderStopReason,
688) -> Option<(BlockAuthorityV1, String, String)> {
689 match stop_reason {
690 ProviderStopReason::Refusal => Some((
691 BlockAuthorityV1::ProviderRefusal,
692 "refusal".to_owned(),
693 "the provider ended the turn with stopReason: refusal".to_owned(),
694 )),
695 ProviderStopReason::ProviderError { message, vendor_code, .. }
696 if provider_error_is_quota(vendor_code.as_deref(), message) =>
697 {
698 let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
699 Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
700 }
701 _ => None,
702 }
703}
704
705fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
746 let ControlEventKind::ProviderEvent {
747 sequence: provider_sequence,
748 source,
749 source_sequence,
750 event: provider_event,
751 } = &event.event
752 else {
753 return None;
754 };
755 if !provider_family_is_live(source.family) {
756 return None;
757 }
758 let kind = match provider_event {
759 ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
760 text: text.clone(),
761 is_delta: *is_delta,
762 },
763 ProviderEvent::Thinking { text } => {
764 AgentStreamChunkKindV1::Thinking { text: text.clone() }
765 }
766 ProviderEvent::InteractionRequested {
767 interaction_kind,
768 tool_name,
769 title,
770 prompt,
771 options,
772 ..
773 } => AgentStreamChunkKindV1::InteractionPrompt {
774 correlation_id: interaction_correlation(
775 event.instance_id,
776 event.generation,
777 *provider_sequence,
778 ),
779 interaction_kind: *interaction_kind,
780 tool_name: tool_name.clone(),
781 title: title.clone(),
790 prompt: prompt.clone(),
791 options: options
792 .iter()
793 .map(|option| AgentStreamInteractionOptionV1 {
794 option_id: option.option_id.clone(),
795 name: option.name.clone(),
796 kind: option.kind.clone(),
797 })
798 .collect(),
799 },
800 ProviderEvent::ConfigOptionsUpdated { options } => {
801 AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
802 }
803 ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
814 current: Some(mode_id.clone()),
815 available: available
816 .iter()
817 .map(|mode| AgentStreamNamedIdV1 {
818 id: mode.id.clone(),
819 name: mode.name.clone(),
820 description: mode.description.clone(),
821 })
822 .collect(),
823 },
824 ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
828 let ProviderHostRequestDecision::Denied { by } = decision else {
829 return None;
830 };
831 let (tool_class, authority, reason_text) =
832 host_request_denied_block(method, *by, reason.as_deref());
833 AgentStreamChunkKindV1::Blocked {
834 correlation_id: None,
835 tool_class,
836 authority,
837 reason_kind: None,
838 reason: reason_text,
839 help: None,
840 }
841 }
842 ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
846 let (authority, reason_kind) =
847 provider_tool_block_authority(non_execution_kind.as_deref())?;
848 let (reason, help) = split_provider_denial_text(output);
849 let (reason, _) =
850 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
851 let help = help.map(|help| {
852 truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
853 });
854 AgentStreamChunkKindV1::Blocked {
855 correlation_id: Some(tool_correlation(event, source, id)),
856 tool_class: tool_class_from_denial_help(help.as_deref()),
857 authority,
858 reason_kind: Some(reason_kind.to_owned()),
859 reason,
860 help,
861 }
862 }
863 ProviderEvent::SessionEnded { stop_reason, .. } => {
867 let (authority, reason_kind, reason) =
868 stop_reason.as_ref().and_then(provider_session_end_block)?;
869 let (reason, _) =
870 truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
871 AgentStreamChunkKindV1::Blocked {
872 correlation_id: None,
873 tool_class: "Session".to_owned(),
874 authority,
875 reason_kind: Some(reason_kind),
876 reason,
877 help: None,
878 }
879 }
880 ProviderEvent::SessionStarted { .. }
881 | ProviderEvent::SessionIdentityObserved { .. }
882 | ProviderEvent::TurnStarted { .. }
883 | ProviderEvent::WorkingObserved
884 | ProviderEvent::ToolStarted { .. }
885 | ProviderEvent::TurnCompleted { .. }
886 | ProviderEvent::ContextWindowUsage { .. }
887 | ProviderEvent::TurnInterrupted
888 | ProviderEvent::Error { .. }
889 | ProviderEvent::Ready
890 | ProviderEvent::InteractionResolved { .. }
891 | ProviderEvent::SubagentStarted { .. }
892 | ProviderEvent::SubagentStopped { .. }
893 | ProviderEvent::RateLimited { .. }
894 | ProviderEvent::UnrecognizedNotification { .. }
895 | ProviderEvent::UserMessage { .. }
896 | ProviderEvent::Plan { .. }
897 | ProviderEvent::AvailableCommandsUpdated { .. }
898 | ProviderEvent::SessionInfoUpdated { .. }
899 | ProviderEvent::UsageUpdated { .. } => return None,
900 };
901 Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
902}
903
904async fn run_native_session_catalog_operation<T, E, F>(
905 catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
906 operation: &'static str,
907 execute: F,
908) -> Result<T, NodeFailure>
909where
910 T: Send + 'static,
911 E: std::fmt::Display + Send + 'static,
912 F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
913{
914 let task = tokio::task::spawn_blocking(move || {
915 let mut catalog = match catalog.try_lock() {
916 Ok(catalog) => catalog,
917 Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
918 Err(std::sync::TryLockError::WouldBlock) => {
919 return Err(failure(
920 NodeFailureCode::BackendBusy,
921 "native session catalog operation is already running",
922 ));
923 }
924 };
925 execute(&mut catalog).map_err(|error| {
926 failure(
927 NodeFailureCode::BackendOperationFailed,
928 &format!("{operation} failed: {error}"),
929 )
930 })
931 });
932 timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
933 .await
934 .map_err(|_| {
935 failure(
936 NodeFailureCode::BackendBusy,
937 &format!("{operation} exceeded its bounded deadline"),
938 )
939 })?
940 .map_err(|_| {
941 failure(
942 NodeFailureCode::BackendOperationFailed,
943 &format!("{operation} task failed"),
944 )
945 })?
946}
947
948fn windows_path_text(path: &OpaqueHostPath) -> &str {
949 path.as_utf8()
950 .expect("Windows node only creates UTF-8 opaque host paths")
951}
952
953fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
954 path.as_utf8()
955 .map(str::to_owned)
956 .ok_or_else(|| failure(
957 NodeFailureCode::InvalidRequest,
958 "this node requires UTF-8 host paths",
959 ))
960}
961
962fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
963 GitWorktreeSnapshot {
964 path: opaque_windows_path(worktree.path),
965 head: worktree.head,
966 branch: worktree.branch,
967 is_bare: worktree.is_bare,
968 is_main: worktree.is_main,
969 locked: worktree.locked,
970 lock_reason: worktree.lock_reason,
971 prunable: worktree.prunable,
972 prunable_reason: worktree.prunable_reason,
973 workspace_id: worktree.workspace_id.take(),
974 }
975}
976
977#[derive(Clone, Debug, Eq, PartialEq)]
978pub struct WorkspaceConfig {
979 workspace_id: WorkspaceId,
980 canonical_root: String,
981 worktree_service_mode: WorktreeServiceMode,
982 managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
983}
984
985impl WorkspaceConfig {
986 pub fn new(
987 workspace_id: WorkspaceId,
988 root: impl AsRef<Path>,
989 ) -> Result<Self, NodeServerError> {
990 let root = root.as_ref();
991 if !root.is_absolute() {
992 return Err(NodeServerError::InvalidWorkspaceRoot {
993 workspace_id,
994 path: root.to_string_lossy().into_owned(),
995 message: "path is not absolute".to_owned(),
996 });
997 }
998 let canonical = std::fs::canonicalize(root).map_err(|error| {
999 NodeServerError::InvalidWorkspaceRoot {
1000 workspace_id: workspace_id.clone(),
1001 path: root.to_string_lossy().into_owned(),
1002 message: error.to_string(),
1003 }
1004 })?;
1005 if !canonical.is_dir() {
1006 return Err(NodeServerError::InvalidWorkspaceRoot {
1007 workspace_id,
1008 path: root.to_string_lossy().into_owned(),
1009 message: "path is not a directory".to_owned(),
1010 });
1011 }
1012 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1013 NodeServerError::InvalidWorkspaceRoot {
1014 workspace_id: workspace_id.clone(),
1015 path: path.to_string_lossy().into_owned(),
1016 message: "canonical path is not valid Unicode".to_owned(),
1017 }
1018 })?;
1019 let canonical_root = platform::normalize_canonical_root(canonical_root);
1020 validate_workspace_root(&workspace_id, &canonical_root)?;
1021 Ok(Self {
1022 workspace_id,
1023 canonical_root,
1024 worktree_service_mode: WorktreeServiceMode::Manual,
1025 managed_worktree_profiles: BTreeMap::new(),
1026 })
1027 }
1028
1029 pub fn workspace_id(&self) -> &WorkspaceId {
1030 &self.workspace_id
1031 }
1032
1033 pub fn canonical_root(&self) -> &str {
1034 &self.canonical_root
1035 }
1036
1037 pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1038 self.worktree_service_mode = mode;
1039 self
1040 }
1041
1042 pub fn with_managed_worktree_profile(
1043 mut self,
1044 profile: ManagedWorktreeProfile,
1045 ) -> Result<Self, NodeServerError> {
1046 profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1047 NodeServerError::InvalidManagedWorktreeProfile {
1048 workspace_id: self.workspace_id.clone(),
1049 profile_id: profile.profile_id().clone(),
1050 message,
1051 }
1052 })?;
1053 if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1054 return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1055 workspace_id: self.workspace_id,
1056 profile_id: profile.profile_id().clone(),
1057 });
1058 }
1059 if self.managed_worktree_profiles.len()
1060 == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1061 {
1062 return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1063 workspace_id: self.workspace_id,
1064 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1065 });
1066 }
1067 self.managed_worktree_profiles
1068 .insert(profile.profile_id().clone(), profile);
1069 Ok(self)
1070 }
1071}
1072
1073#[derive(Clone)]
1074pub struct NodeServerConfig {
1075 pub endpoint: String,
1076 api_listen: Option<std::net::SocketAddr>,
1077 bridge_listen: Option<std::net::SocketAddr>,
1080 bridge_token: Option<String>,
1083 bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1086 call_home: Option<std::net::SocketAddr>,
1089 call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1093 pub node_id: NodeId,
1094 pub workspaces: Vec<WorkspaceConfig>,
1095 access_token: String,
1096 pub runtime: NativeRuntimeConfig,
1097 session_record_retention: SessionRecordRetentionConfig,
1098 state_path: Option<PathBuf>,
1099 spawn_profiles: SpawnProfileRegistry,
1100 session_environment: Option<NodeSessionEnvironmentConfig>,
1101 history: Option<NativeHistoryConfig>,
1102 harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1103 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1107 #[cfg(feature = "fixture")]
1108 fixture_raw_pty_runtime: bool,
1109}
1110
1111#[derive(Clone)]
1112struct NodeSessionEnvironmentConfig {
1113 root: PathBuf,
1114 resolver: Arc<dyn NodeSecretResolver>,
1115}
1116
1117impl NodeServerConfig {
1118 pub fn new(
1119 endpoint: impl Into<String>,
1120 access_token: impl Into<String>,
1121 node_id: NodeId,
1122 workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1123 ) -> Result<Self, NodeServerError> {
1124 let endpoint = endpoint.into();
1125 let access_token = access_token.into();
1126 if !platform::validate_endpoint(&endpoint) {
1127 return Err(NodeServerError::InvalidEndpoint);
1128 }
1129 if access_token.is_empty() || access_token.len() > 4_096 {
1130 return Err(NodeServerError::InvalidAccessToken);
1131 }
1132 let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1133 if workspaces.is_empty() {
1134 return Err(NodeServerError::NoWorkspaces);
1135 }
1136 let mut workspace_ids = BTreeMap::new();
1137 let mut workspace_roots = BTreeMap::new();
1138 for workspace in &workspaces {
1139 if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1140 && workspace.managed_worktree_profiles.is_empty()
1141 {
1142 return Err(NodeServerError::ManagedWorktreeProfileRequired(
1143 workspace.workspace_id.clone(),
1144 ));
1145 }
1146 if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1147 && !workspace.managed_worktree_profiles.is_empty()
1148 {
1149 return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1150 workspace.workspace_id.clone(),
1151 ));
1152 }
1153 if workspace_ids
1154 .insert(workspace.workspace_id.clone(), ())
1155 .is_some()
1156 {
1157 return Err(NodeServerError::DuplicateWorkspaceId(
1158 workspace.workspace_id.clone(),
1159 ));
1160 }
1161 let root_key = platform::root_identity(&workspace.canonical_root);
1162 if let Some(existing) = workspace_roots.insert(
1163 root_key,
1164 workspace.workspace_id.clone(),
1165 ) {
1166 return Err(NodeServerError::DuplicateWorkspaceRoot {
1167 first: existing,
1168 second: workspace.workspace_id.clone(),
1169 root: workspace.canonical_root.clone(),
1170 });
1171 }
1172 }
1173 Ok(Self {
1174 endpoint,
1175 api_listen: None,
1176 bridge_listen: None,
1177 bridge_token: None,
1178 bridge_underlay: None,
1179 call_home: None,
1180 call_home_identity: None,
1181 node_id,
1182 workspaces,
1183 access_token,
1184 runtime: NativeRuntimeConfig {
1187 worker_poll_interval_ms: 4,
1188 ..NativeRuntimeConfig::default()
1189 },
1190 session_record_retention: SessionRecordRetentionConfig::default(),
1191 state_path: None,
1192 spawn_profiles: SpawnProfileRegistry::default(),
1193 session_environment: None,
1194 history: None,
1195 harness_mcp_helper: None,
1196 network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1197 #[cfg(feature = "fixture")]
1198 fixture_raw_pty_runtime: false,
1199 })
1200 }
1201
1202 pub fn with_api_listen(
1203 mut self,
1204 api_listen: std::net::SocketAddr,
1205 ) -> Result<Self, NodeServerError> {
1206 if !api_listen.ip().is_loopback() {
1207 return Err(NodeServerError::InvalidApiListen(api_listen));
1208 }
1209 self.api_listen = Some(api_listen);
1210 Ok(self)
1211 }
1212
1213 pub fn with_bridge_listen(
1217 mut self,
1218 bridge_listen: std::net::SocketAddr,
1219 ) -> Result<Self, NodeServerError> {
1220 if !bridge_listen.ip().is_loopback() {
1221 return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1222 }
1223 self.bridge_listen = Some(bridge_listen);
1224 Ok(self)
1225 }
1226
1227 pub fn with_bridge_token(
1230 mut self,
1231 bridge_token: impl Into<String>,
1232 ) -> Result<Self, NodeServerError> {
1233 let bridge_token = bridge_token.into();
1234 if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1235 return Err(NodeServerError::InvalidBridgeToken);
1236 }
1237 self.bridge_token = Some(bridge_token);
1238 Ok(self)
1239 }
1240
1241 pub fn with_bridge_underlay(
1246 mut self,
1247 bind: std::net::SocketAddr,
1248 transport_key: [u8; 32],
1249 underlay_token: impl Into<String>,
1250 ) -> Result<Self, NodeServerError> {
1251 if self.bridge_listen.is_none() {
1252 return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1253 }
1254 if self.bridge_token.is_none() {
1255 return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1256 }
1257 let underlay_token = underlay_token.into();
1258 if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1259 return Err(NodeServerError::InvalidBridgeUnderlayToken);
1260 }
1261 self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1263 bind,
1264 transport_key,
1265 underlay_token,
1266 });
1267 Ok(self)
1268 }
1269
1270 pub fn with_call_home(
1291 mut self,
1292 relay: std::net::SocketAddr,
1293 ) -> Result<Self, NodeServerError> {
1294 if relay.port() == 0 || relay.ip().is_unspecified() {
1297 return Err(NodeServerError::InvalidCallHome(relay));
1298 }
1299 self.call_home = Some(relay);
1300 Ok(self)
1301 }
1302
1303 pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1305 self.call_home_identity = Some(identity);
1306 self
1307 }
1308
1309 pub fn with_state_path(
1310 mut self,
1311 state_path: impl Into<PathBuf>,
1312 ) -> Result<Self, NodeServerError> {
1313 let state_path = state_path.into();
1314 if !state_path.is_absolute() || state_path.file_name().is_none() {
1315 return Err(NodeServerError::InvalidStatePath(
1316 state_path.to_string_lossy().into_owned(),
1317 ));
1318 }
1319 self.state_path = Some(state_path);
1320 Ok(self)
1321 }
1322
1323 pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1324 self.spawn_profiles = spawn_profiles;
1325 self
1326 }
1327
1328 pub fn with_session_record_retention(
1334 mut self,
1335 retention: SessionRecordRetentionConfig,
1336 ) -> Self {
1337 self.session_record_retention = retention;
1338 self
1339 }
1340
1341 pub fn with_session_environment_materialization(
1342 mut self,
1343 root: impl Into<PathBuf>,
1344 resolver: Arc<dyn NodeSecretResolver>,
1345 ) -> Result<Self, NodeServerError> {
1346 let root = root.into();
1347 if !root.is_absolute() {
1348 return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1349 }
1350 self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1351 Ok(self)
1352 }
1353
1354 pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1357 self.history = Some(history);
1358 self
1359 }
1360
1361 pub fn with_harness_mcp_helper(
1367 mut self,
1368 helper_program: impl Into<PathBuf>,
1369 ) -> Result<Self, NodeServerError> {
1370 self.harness_mcp_helper = Some(
1371 ReviewedHarnessMcpProgram::review(helper_program.into())
1372 .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1373 );
1374 Ok(self)
1375 }
1376
1377 pub fn with_network_allowlist_catalog(
1382 mut self,
1383 catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1384 ) -> Result<Self, NodeServerError> {
1385 if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1386 return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1387 max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1388 });
1389 }
1390 self.network_allowlist_catalog = catalog;
1391 Ok(self)
1392 }
1393
1394 pub fn with_network_allowlist_catalog_file(
1398 self,
1399 path: impl AsRef<std::path::Path>,
1400 ) -> Result<Self, NodeServerError> {
1401 let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1402 .map_err(NodeServerError::from)?;
1403 self.with_network_allowlist_catalog(catalog)
1404 }
1405}
1406
1407pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1408 platform::default_state_path(node_id.as_str())
1409 .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1410}
1411
1412pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1413 platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1414}
1415
1416fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1417 let parent = state_path.parent()?;
1418 let mut name = state_path.file_name()?.to_os_string();
1419 name.push(".delivery-store");
1420 Some(parent.join(name))
1421}
1422
1423fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1424 let parent = state_path.parent()?;
1425 let mut name = state_path.file_name()?.to_os_string();
1426 name.push(".context-pack-store");
1427 Some(parent.join(name))
1428}
1429
1430pub struct NodeServer {
1431 config: NodeServerConfig,
1432 runtime: NativeRuntime,
1433 shared: Arc<NodeShared>,
1434 events: EventSubscription,
1435 state_path_lock: Option<session_registry::StatePathLock>,
1436}
1437
1438#[cfg(feature = "fixture")]
1439#[derive(Clone, Default)]
1440pub struct SpawnManagedWorktreeV2FailureProbe {
1441 latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1442}
1443
1444#[cfg(feature = "fixture")]
1445impl SpawnManagedWorktreeV2FailureProbe {
1446 pub fn latest_code(&self) -> Option<NodeFailureCode> {
1447 *self
1448 .latest_code
1449 .lock()
1450 .unwrap_or_else(|poisoned| poisoned.into_inner())
1451 }
1452
1453 fn record(&self, code: Option<NodeFailureCode>) {
1454 *self
1455 .latest_code
1456 .lock()
1457 .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1458 }
1459}
1460
1461#[cfg(all(feature = "fixture", windows))]
1462struct IsolatedCodexHomeFixtureEnvironment;
1463
1464#[cfg(all(feature = "fixture", windows))]
1465impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1466 for IsolatedCodexHomeFixtureEnvironment
1467{
1468 fn resolve_child_environment(
1469 &self,
1470 ) -> Result<
1471 Vec<gate4agent_catalog::EnvMutation>,
1472 gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1473 > {
1474 Ok(vec![gate4agent_catalog::EnvMutation {
1475 key: OsString::from("USERPROFILE"),
1476 value: None,
1477 }])
1478 }
1479}
1480
1481impl NodeServer {
1482 #[cfg(unix)]
1486 pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1487 self.shared.signer_proxy.set(proxy).is_ok()
1488 }
1489
1490 pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1491 self.shared.m4a_accounts.as_ref()?;
1492 Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1493 }
1494
1495 pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1496 let catalog = active_registry()?;
1497 Self::new_with_registry(config, catalog)
1498 }
1499
1500 #[cfg(feature = "fixture")]
1501 pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1502 let mut spec = gate4agent_testkit::interactive_agent_spec();
1503 spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1504 spec.display_name = "Controlled Claude PTY fixture".to_owned();
1505 Self::new_fixture_with_spec(config, spec)
1506 }
1507
1508 #[cfg(feature = "fixture")]
1509 pub fn new_clean_exit_fixture(
1510 config: NodeServerConfig,
1511 fixture_root: PathBuf,
1512 started_marker: PathBuf,
1513 release_signal: PathBuf,
1514 ) -> Result<Self, NodeServerError> {
1515 let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1516 &fixture_root,
1517 &started_marker,
1518 &release_signal,
1519 )
1520 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1521 spec.id = AgentId::new("claude")
1522 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1523 spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1524 Self::new_fixture_with_spec(config, spec)
1525 }
1526
1527 #[cfg(all(feature = "fixture", windows))]
1537 pub fn new_durable_context_pack_clean_exit_fixture(
1538 config: NodeServerConfig,
1539 fixture_root: PathBuf,
1540 started_marker: PathBuf,
1541 release_signal: PathBuf,
1542 session_id: &str,
1543 ) -> Result<Self, NodeServerError> {
1544 let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1545 &fixture_root,
1546 &started_marker,
1547 &release_signal,
1548 session_id,
1549 )
1550 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1551 let source_id = AgentId::new("claude")
1552 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1553 let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1554 NodeServerError::Registry(
1555 "durable context pack clean-exit fixture source adapter is unavailable"
1556 .to_owned(),
1557 )
1558 })?;
1559 source_spec.capabilities.adapters.history =
1560 source_provider.capabilities.adapters.history.clone();
1561 source_spec.id = source_id;
1562 source_spec.display_name =
1563 "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1564 source_spec.detection.command =
1570 "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1571 source_spec.detection.aliases.clear();
1572
1573 let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1574 target_spec.id = AgentId::new("codex")
1575 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1576 target_spec.display_name =
1577 "Controlled Codex durable context-pack target fixture".to_owned();
1578 target_spec.detection.command =
1579 "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1580 target_spec.detection.aliases.clear();
1581
1582 let catalog = AgentRegistry::new([source_spec, target_spec])
1583 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1584 let mut server = Self::new_with_registry(config, catalog)?;
1585 Arc::get_mut(&mut server.shared)
1586 .expect("fixture Node shared state must remain uniquely owned before run")
1587 .fixture_semantic_hook_policy = true;
1588 Ok(server)
1589 }
1590
1591 #[cfg(feature = "fixture")]
1592 pub fn new_monitoring_hook_fixture(
1593 config: NodeServerConfig,
1594 ) -> Result<Self, NodeServerError> {
1595 let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1596 spec.id = AgentId::new("claude")
1597 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1598 let mut server = Self::new_fixture_with_spec(config, spec)?;
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(all(feature = "fixture", windows))]
1606 pub fn new_provider_bundle_argv_fixture(
1607 config: NodeServerConfig,
1608 agent_id: AgentId,
1609 proof_path: PathBuf,
1610 ) -> Result<Self, NodeServerError> {
1611 let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1612 Self::new_fixture_with_spec(config, spec)
1613 }
1614
1615 #[cfg(all(feature = "fixture", windows))]
1616 pub fn new_provider_bundle_argv_hold_fixture(
1617 config: NodeServerConfig,
1618 agent_id: AgentId,
1619 proof_path: PathBuf,
1620 release_signal: PathBuf,
1621 ) -> Result<Self, NodeServerError> {
1622 let spec = Self::provider_bundle_argv_fixture_spec(
1623 agent_id,
1624 proof_path,
1625 Some(release_signal),
1626 )?;
1627 Self::new_fixture_with_spec(config, spec)
1628 }
1629
1630 #[cfg(all(feature = "fixture", windows))]
1631 fn provider_bundle_argv_fixture_spec(
1632 agent_id: AgentId,
1633 proof_path: PathBuf,
1634 release_signal: Option<PathBuf>,
1635 ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1636 if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1637 return Err(NodeServerError::Registry(
1638 "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1639 ));
1640 }
1641 let mut spec = gate4agent_testkit::interactive_agent_spec();
1642 spec.id = agent_id.clone();
1643 spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1644 let provider = builtin_registry()
1645 .get(&agent_id)
1646 .ok_or_else(|| {
1647 NodeServerError::Registry(
1648 "provider bundle argv fixture adapter is unavailable".to_owned(),
1649 )
1650 })?;
1651 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1652 if !proof_path.is_absolute()
1653 || !proof_path
1654 .parent()
1655 .is_some_and(|parent| parent.is_dir())
1656 {
1657 return Err(NodeServerError::Registry(
1658 "provider bundle argv proof path requires an existing absolute parent"
1659 .to_owned(),
1660 ));
1661 }
1662 if let Some(release_signal) = release_signal.as_ref() {
1663 if !release_signal.is_absolute()
1664 || !release_signal
1665 .parent()
1666 .is_some_and(|parent| parent.is_dir())
1667 {
1668 return Err(NodeServerError::Registry(
1669 "provider bundle argv release path requires an existing absolute parent"
1670 .to_owned(),
1671 ));
1672 }
1673 if release_signal.exists() {
1674 return Err(NodeServerError::Registry(
1675 "provider bundle argv release path must not exist".to_owned(),
1676 ));
1677 }
1678 let proof_parent = std::fs::canonicalize(
1679 proof_path
1680 .parent()
1681 .expect("validated provider bundle proof parent"),
1682 )
1683 .map_err(|_| {
1684 NodeServerError::Registry(
1685 "provider bundle argv proof parent is unavailable".to_owned(),
1686 )
1687 })?;
1688 let release_parent = std::fs::canonicalize(
1689 release_signal
1690 .parent()
1691 .expect("validated provider bundle release parent"),
1692 )
1693 .map_err(|_| {
1694 NodeServerError::Registry(
1695 "provider bundle argv release parent is unavailable".to_owned(),
1696 )
1697 })?;
1698 if proof_parent != release_parent {
1699 return Err(NodeServerError::Registry(
1700 "provider bundle argv release path must share the proof parent".to_owned(),
1701 ));
1702 }
1703 }
1704 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1705 NodeServerError::Registry(
1706 "provider bundle argv proof path is not valid Unicode".to_owned(),
1707 )
1708 })?;
1709 let release_signal = release_signal.map(|release_signal| {
1710 release_signal.into_os_string().into_string().map_err(|_| {
1711 NodeServerError::Registry(
1712 "provider bundle argv release path is not valid Unicode".to_owned(),
1713 )
1714 })
1715 }).transpose()?;
1716 let script = spec
1717 .launch
1718 .fixed_args
1719 .pop()
1720 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1721 let bundle_validation = if agent_id.as_str() == "codex" {
1722 "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') }"
1723 } else if agent_id.as_str() == "claude" {
1724 "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 }"
1725 } else {
1726 "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 }"
1727 };
1728 if let Some(release_signal) = release_signal {
1729 spec.launch.fixed_args.push(format!(
1730 "& {{ 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} }}",
1731 ));
1732 spec.launch.fixed_args.push(proof_path);
1733 spec.launch.fixed_args.push(release_signal);
1734 } else {
1735 spec.launch.fixed_args.push(format!(
1736 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1737 ));
1738 spec.launch.fixed_args.push(proof_path);
1739 }
1740 Ok(spec)
1741 }
1742
1743 #[cfg(all(feature = "fixture", windows))]
1744 pub fn new_context_pack_fixture(
1745 config: NodeServerConfig,
1746 proof_path: PathBuf,
1747 ) -> Result<Self, NodeServerError> {
1748 let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1749 Self::new_with_registry(config, catalog)
1750 }
1751
1752 #[cfg(all(feature = "fixture", windows))]
1753 pub fn new_monitoring_context_pack_fixture(
1754 config: NodeServerConfig,
1755 proof_path: PathBuf,
1756 ) -> Result<Self, NodeServerError> {
1757 let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1758 let mut server = Self::new_with_registry(config, catalog)?;
1759 Arc::get_mut(&mut server.shared)
1760 .expect("fixture Node shared state must remain uniquely owned before run")
1761 .fixture_semantic_hook_policy = true;
1762 Ok(server)
1763 }
1764
1765 #[cfg(all(feature = "fixture", windows))]
1766 pub fn install_isolated_codex_home_environment_fixture(
1767 &mut self,
1768 profile_id: SpawnEnvironmentProfileId,
1769 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1770 ) -> Result<(), NodeServerError> {
1771 let provider = AgentId::new("codex")
1772 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1773 let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1774 gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1775 "{}-pty",
1776 profile_id.as_str(),
1777 ))
1778 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1779 provider.clone(),
1780 TransportKind::Pty,
1781 vec![OsString::from("USERPROFILE")],
1782 Arc::new(IsolatedCodexHomeFixtureEnvironment),
1783 )
1784 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1785 let materialization = NodeSessionMaterializationProfile::new(
1786 Vec::new(),
1787 vec![crate::session_environment::NodeSessionPathBinding::new(
1788 "CODEX_HOME",
1789 crate::session_environment::NodeSessionPathClass::ProviderHome,
1790 )
1791 .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1792 Vec::new(),
1793 )
1794 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1795 self.install_environment_profile(
1796 NodeEnvironmentProfile::new_with_materialization(
1797 profile_id,
1798 profile_revision,
1799 provider,
1800 [native_profile],
1801 Some(materialization),
1802 )
1803 .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1804 )
1805 }
1806
1807 #[cfg(all(feature = "fixture", windows))]
1808 pub fn new_context_only_proof_fixture(
1809 config: NodeServerConfig,
1810 target_provider: AgentId,
1811 proof_path: PathBuf,
1812 ) -> Result<Self, NodeServerError> {
1813 if target_provider.as_str() != "kimi" {
1814 return Err(NodeServerError::Registry(
1815 "context-only proof fixture requires Kimi".to_owned(),
1816 ));
1817 }
1818 let catalog = Self::context_pack_fixture_catalog(
1819 proof_path,
1820 Some(&target_provider),
1821 false,
1822 )?;
1823 Self::new_with_registry(config, catalog)
1824 }
1825
1826 #[cfg(all(feature = "fixture", windows))]
1827 fn context_pack_fixture_catalog(
1828 proof_path: PathBuf,
1829 context_only_target: Option<&AgentId>,
1830 monitoring_claude_source: bool,
1831 ) -> Result<AgentRegistry, NodeServerError> {
1832 if !proof_path.is_absolute()
1833 || !proof_path
1834 .parent()
1835 .is_some_and(|parent| parent.is_dir())
1836 {
1837 return Err(NodeServerError::Registry(
1838 "context pack proof path requires an existing absolute parent".to_owned(),
1839 ));
1840 }
1841 let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1842 NodeServerError::Registry(
1843 "context pack proof path is not valid Unicode".to_owned(),
1844 )
1845 })?;
1846 let providers = ["claude", "codex", "grok", "kimi"];
1847 let builtins = builtin_registry();
1848 let mut specs = Vec::with_capacity(providers.len());
1849 for provider_id in providers {
1850 let agent_id = AgentId::new(provider_id)
1851 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1852 let provider = builtins.get(&agent_id).ok_or_else(|| {
1853 NodeServerError::Registry(
1854 "context pack fixture provider adapter is unavailable".to_owned(),
1855 )
1856 })?;
1857 let monitored_provider =
1858 monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1859 let mut spec = if monitored_provider {
1860 gate4agent_testkit::monitoring_hook_agent_spec()
1861 } else {
1862 gate4agent_testkit::interactive_agent_spec()
1863 };
1864 spec.id = agent_id.clone();
1865 spec.display_name = format!("Controlled {agent_id} context pack fixture");
1866 spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1867 spec.detection.aliases.clear();
1868 spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1869 if provider_id == "codex" {
1870 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1871 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1872 })?;
1873 let validation = r#"
1874$bundleArgs = @($bundleArgs)
1875if ($bundleArgs.Count -ne 0) { exit 91 }
1876$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1877$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1878$cwd = (Get-Location).ProviderPath
1879if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1880$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1881if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1882$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1883if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1884$sha = [Security.Cryptography.SHA256]::Create()
1885try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1886if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1887if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1888$contextPath = Join-Path $contextRoot 'context-pack.json'
1889if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1890$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1891if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1892$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1893$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1894$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1895$separator = [IO.Path]::DirectorySeparatorChar
1896if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1897if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1898$rawContext = [IO.File]::ReadAllText($contextPath)
1899if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1900try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1901$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1902$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1903foreach ($property in @($document.PSObject.Properties.Name)) {
1904 if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1905}
1906foreach ($property in $requiredTopLevel) {
1907 if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1908}
1909if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1910if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1911$messages = @($document.retained_messages)
1912if ($messages.Count -eq 0) { exit 107 }
1913foreach ($message in $messages) {
1914 $messageProperties = @($message.PSObject.Properties.Name)
1915 foreach ($property in $messageProperties) {
1916 if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1917 }
1918 if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1919}
1920$sha = [Security.Cryptography.SHA256]::Create()
1921try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1922[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))
1923$rawProof = [IO.File]::ReadAllText($proofPath)
1924if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1925[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1926"#;
1927 spec.launch.fixed_args.push(format!(
1928 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1929 ));
1930 spec.launch.fixed_args.push(proof_path.clone());
1931 } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1932 let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1933 NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1934 })?;
1935 let validation = r#"
1936$bundleArgs = @($bundleArgs)
1937if ($bundleArgs.Count -ne 0) { exit 91 }
1938$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1939$cwd = (Get-Location).ProviderPath
1940if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1941$contextPath = Join-Path $contextRoot 'context-pack.json'
1942if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1943$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1944if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1945$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1946$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1947$separator = [IO.Path]::DirectorySeparatorChar
1948if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1949try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1950if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1951if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1952$messages = @($document.retained_messages)
1953if ($messages.Count -eq 0) { exit 99 }
1954$hasUser = $false
1955$hasAssistant = $false
1956foreach ($message in $messages) {
1957 if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1958 if ([string]$message.role -ceq 'user') { $hasUser = $true }
1959 elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1960 else { exit 101 }
1961}
1962if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1963$sha = [Security.Cryptography.SHA256]::Create()
1964try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1965[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1966[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1967"#;
1968 spec.launch.fixed_args.push(format!(
1969 "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1970 ));
1971 spec.launch.fixed_args.push(proof_path.clone());
1972 }
1973 specs.push(spec);
1974 }
1975 let catalog = AgentRegistry::new(specs)
1976 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1977 Ok(catalog)
1978 }
1979
1980 #[cfg(feature = "fixture")]
1981 pub fn new_exact_launcher_fixture(
1982 config: NodeServerConfig,
1983 agent_id: AgentId,
1984 launcher: String,
1985 ) -> Result<Self, NodeServerError> {
1986 if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1987 return Err(NodeServerError::Registry(
1988 "exact launcher fixture requires a supported provider".to_owned(),
1989 ));
1990 }
1991 if launcher.contains('\0') {
1992 return Err(NodeServerError::Registry(
1993 "exact launcher fixture path contains NUL".to_owned(),
1994 ));
1995 }
1996 let launcher_path = Path::new(&launcher);
1997 if !launcher_path.is_absolute() {
1998 return Err(NodeServerError::Registry(
1999 "exact launcher fixture path must be absolute".to_owned(),
2000 ));
2001 }
2002 let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2003 NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2004 })?;
2005 if !metadata.is_file() {
2006 return Err(NodeServerError::Registry(
2007 "exact launcher fixture path must be a regular file".to_owned(),
2008 ));
2009 }
2010 let mut spec = builtin_registry()
2011 .get(&agent_id)
2012 .ok_or_else(|| {
2013 NodeServerError::Registry(
2014 "exact launcher fixture provider is unavailable".to_owned(),
2015 )
2016 })?
2017 .clone();
2018 spec.launch.program = launcher;
2019 Self::new_fixture_with_spec(config, spec)
2020 }
2021
2022 #[cfg(feature = "fixture")]
2031 fn h3b_provider_spec_with_program_and_args(
2032 agent_id: AgentId,
2033 provider_program: PathBuf,
2034 fixed_args: Vec<OsString>,
2035 ) -> Result<AgentSpec, NodeServerError> {
2036 if !provider_program.is_absolute() || fixed_args.len() > 32 {
2037 return Err(NodeServerError::Registry(
2038 "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2039 ));
2040 }
2041 let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2042 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2043 })?;
2044 if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2045 return Err(NodeServerError::Registry(
2046 "H3B fixture provider must be an exact regular file".to_owned(),
2047 ));
2048 }
2049 let program = provider_program.into_os_string().into_string().map_err(|_| {
2050 NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2051 })?;
2052 let fixed_args = fixed_args.into_iter().map(|argument| {
2053 argument.into_string().map_err(|_| NodeServerError::Registry(
2054 "H3B fixture argument is not Unicode".to_owned(),
2055 ))
2056 }).collect::<Result<Vec<_>, _>>()?;
2057 let argument_bytes = fixed_args.iter()
2058 .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2059 if program.contains('\0')
2060 || fixed_args.iter().any(|argument| argument.contains('\0'))
2061 || !matches!(argument_bytes, Some(total) if total <= 65_536)
2062 {
2063 return Err(NodeServerError::Registry(
2064 "H3B fixture provider or arguments are invalid".to_owned(),
2065 ));
2066 }
2067 let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2068 NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2069 })?.clone();
2070 let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2071 .ok_or_else(|| NodeServerError::Registry(
2072 "H3B fixture provider basename is unavailable".to_owned(),
2073 ))?.to_owned();
2074 spec.detection.command = process_name.clone();
2075 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2076 name: process_name,
2077 }];
2078 spec.launch.program = program;
2079 spec.launch.fixed_args = fixed_args;
2080 Ok(spec)
2081 }
2082
2083 #[cfg(feature = "fixture")]
2084 pub fn new_harness_mcp_proxy_fixture(
2085 config: NodeServerConfig,
2086 provider_program: PathBuf,
2087 fixed_args: Vec<OsString>,
2088 harness_mcp_program: PathBuf,
2089 ) -> Result<Self, NodeServerError> {
2090 let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2091 let agent_id = AgentId::new("codex")
2092 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2093 let mut spec =
2094 Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2095 spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2096 config.fixture_raw_pty_runtime = true;
2097 Self::new_fixture_with_spec(config, spec)
2098 }
2099
2100 #[cfg(feature = "fixture")]
2124 pub fn new_harness_mcp_acp_launcher_fixture(
2125 config: NodeServerConfig,
2126 helper_program: PathBuf,
2127 provider: AgentId,
2128 program: PathBuf,
2129 args: Vec<OsString>,
2130 ) -> Result<Self, NodeServerError> {
2131 if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2132 return Err(NodeServerError::Registry(
2133 "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2134 ));
2135 }
2136 let config = config.with_harness_mcp_helper(helper_program)?;
2137 let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2138 let launch = spec.launch.clone();
2139 let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2140 NodeServerError::Registry(
2141 "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2142 .to_owned(),
2143 )
2144 })?;
2145 acp.launch_override = Some(launch);
2153 spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2154 Self::new_fixture_with_spec(config, spec)
2155 }
2156
2157 #[cfg(feature = "fixture")]
2158 pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2159 let mut spec = gate4agent_testkit::interactive_agent_spec();
2160 let claude_id = AgentId::new("claude")
2161 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2162 let claude = builtin_registry()
2163 .get(&claude_id)
2164 .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2165 spec.id = claude_id;
2166 spec.display_name = "Resumable Claude PTY fixture".to_owned();
2167 spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2168 name: "claude".to_owned(),
2169 }];
2170 spec.capabilities.transports.pty_adapter = claude
2171 .capabilities
2172 .transports
2173 .pty_adapter
2174 .clone();
2175 spec.capabilities.adapters.resume = claude
2176 .capabilities
2177 .adapters
2178 .resume
2179 .clone();
2180 let script = spec
2181 .launch
2182 .fixed_args
2183 .pop()
2184 .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2185 spec.launch.fixed_args.push(format!(
2186 "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2187 ));
2188 Self::new_fixture_with_spec(config, spec)
2189 }
2190
2191 #[cfg(feature = "fixture")]
2192 pub fn new_cmd_cwd_fixture(
2193 config: NodeServerConfig,
2194 command: String,
2195 ) -> Result<Self, NodeServerError> {
2196 let mut spec = gate4agent_testkit::interactive_agent_spec();
2197 spec.id = AgentId::new("claude")
2198 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2199 spec.display_name = "Controlled cmd cwd fixture".to_owned();
2200 spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2201 spec.launch.program = command;
2202 spec.launch.fixed_args.clear();
2203 Self::new_fixture_with_spec(config, spec)
2204 }
2205
2206 #[cfg(feature = "fixture")]
2207 fn new_fixture_with_spec(
2208 config: NodeServerConfig,
2209 spec: gate4agent_types::AgentSpec,
2210 ) -> Result<Self, NodeServerError> {
2211 let catalog = AgentRegistry::new([spec])
2212 .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2213 Self::new_with_registry(config, catalog)
2214 }
2215
2216 fn new_with_registry(
2217 config: NodeServerConfig,
2218 catalog: AgentRegistry,
2219 ) -> Result<Self, NodeServerError> {
2220 for profile in config.spawn_profiles.iter() {
2221 if catalog.get(&profile.provider).is_none() {
2222 return Err(NodeServerError::Registry(
2223 "spawn profile references an unavailable provider".to_owned(),
2224 ));
2225 }
2226 }
2227 let input_settle_timeout_ms = catalog
2228 .iter()
2229 .map(|spec| spec.readiness.timeout_ms)
2230 .max()
2231 .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2232 .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2233 .max(MUTATION_SETTLE_TIMEOUT_MS);
2234 let (provider_contracts, provider_adapter_contracts) =
2235 provider_contract_manifest(&catalog)?;
2236 let compatibility_support = node_compatibility_support_for_manifest(
2237 &provider_contracts,
2238 &provider_adapter_contracts,
2239 )?;
2240 validate_node_negotiated_handshake_capacity(&compatibility_support)
2241 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2242 let enabled_providers = provider_contracts
2243 .iter()
2244 .map(|contract| contract.provider.clone())
2245 .collect();
2246 #[cfg(feature = "fixture")]
2247 let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2248 #[cfg(not(feature = "fixture"))]
2249 let fixture_raw_pty_runtime = false;
2250 let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2251 let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2252 crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2253 })).expect("fixture provider catalog remains bounded");
2254 (None, statuses)
2255 } else {
2256 let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2257 let statuses = monitor.collect();
2258 (Some(monitor), statuses)
2259 };
2260 let state_path_lock =
2261 session_registry::StatePathLock::acquire(config.state_path.as_deref())
2262 .map_err(|error| {
2263 durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2264 })?;
2265 let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2266 Some(state_path) => {
2267 let delivery_root = delivery_store_root_for_state_path(state_path)
2268 .ok_or_else(|| {
2269 NodeServerError::DeliveryStore(
2270 "delivery store root could not be derived from state path".to_owned(),
2271 )
2272 })?;
2273 let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2274 let detail = match &error {
2275 DeliveryStoreError::Storage(io_error) => {
2276 format!("{error}: {io_error}")
2277 }
2278 _ => error.to_string(),
2279 };
2280 NodeServerError::DeliveryStore(detail)
2281 })?;
2282 (Some(store), bundles)
2283 }
2284 None => (None, Vec::new()),
2285 };
2286 let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2287 NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2288 })?;
2289 let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2290 Some(state_path) => {
2291 let context_pack_root = context_pack_store_root_for_state_path(state_path)
2292 .ok_or(NodeServerError::ContextPackStore)?;
2293 let (store, packs) = ContextPackStore::open(context_pack_root)
2294 .map_err(|_| NodeServerError::ContextPackStore)?;
2295 (Some(store), packs)
2296 }
2297 None => (None, Vec::new()),
2298 };
2299 let mut durable_context_catalog = ContextPackCatalog::default();
2300 for pack in durable_context_packs {
2301 durable_context_catalog
2302 .insert(pack)
2303 .map_err(|_| NodeServerError::ContextPackStore)?;
2304 }
2305 let session_environment_materializer = config
2306 .session_environment
2307 .as_ref()
2308 .map(|environment| {
2309 SessionEnvironmentMaterializer::new(
2310 environment.root.clone(),
2311 Arc::clone(&environment.resolver),
2312 )
2313 .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2314 })
2315 .transpose()?;
2316 #[cfg(unix)]
2319 let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2320 let native_session_catalog = config.history.clone().map(|history| {
2321 Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2322 });
2323 let (handle, runtime) = match config.history.clone() {
2324 Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2325 None => NativeRuntime::new(catalog, config.runtime),
2326 };
2327 let native_launch_profile_control = runtime.native_launch_profile_control();
2328 let harness_mcp_registry = config
2329 .harness_mcp_helper
2330 .clone()
2331 .map(HarnessMcpProxyRegistry::new);
2332 let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2333 let incarnation_id = random_incarnation_id()
2334 .map_err(NodeServerError::IncarnationIdentity)?;
2335 let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2336 .map_err(durable_state_load_error)?;
2337 let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2338 let managed_worktree_tombstones =
2339 std::mem::take(&mut loaded.managed_worktree_tombstones);
2340 let materializations = std::mem::take(&mut loaded.materializations);
2341 let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2342 let (workspaces, records, persistence_warning) =
2343 merge_durable_state(&config.workspaces, loaded)?;
2344 let mut shared = NodeShared::new_with_incarnation(
2345 handle,
2346 config.access_token.clone(),
2347 config.node_id.clone(),
2348 incarnation_id,
2349 workspaces,
2350 enabled_providers,
2351 provider_runtime_statuses,
2352 provider_runtime_monitor,
2353 provider_contracts,
2354 provider_adapter_contracts,
2355 config.spawn_profiles.clone(),
2356 Some(native_launch_profile_control),
2357 native_session_catalog,
2358 session_environment_materializer,
2359 config.state_path.clone(),
2360 records,
2361 managed_worktrees,
2362 managed_worktree_tombstones,
2363 materializations,
2364 persistence_warning,
2365 input_settle_timeout_ms,
2366 );
2367 shared.harness_mcp_registry = harness_mcp_registry.clone();
2368 shared.session_record_retention = config.session_record_retention;
2369 shared.network_allowlist_catalog =
2370 RwLock::new(config.network_allowlist_catalog.clone());
2371 shared.bundle_catalog = RwLock::new(delivered_catalog);
2372 shared.delivery_store = Mutex::new(delivery_store);
2373 shared.context_catalog = RwLock::new(durable_context_catalog);
2374 shared.context_pack_store = Mutex::new(context_pack_store);
2375 shared.managed_spawn_replays = Mutex::new(
2376 managed_spawn_replays
2377 .into_iter()
2378 .map(|record| (record.idempotency_key.clone(), record))
2379 .collect(),
2380 );
2381 let shared = Arc::new(shared);
2382 if let Some(registry) = harness_mcp_registry {
2383 let weak = Arc::downgrade(&shared);
2384 registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2385 Some(shared) => shared.publish_transient(event),
2386 None => false,
2387 }));
2388 }
2389 if shared.persistence_error().is_none() {
2390 shared.persist_state().map_err(|error| {
2391 durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2392 })?;
2393 }
2394 Ok(Self {
2395 config,
2396 runtime,
2397 shared,
2398 events,
2399 state_path_lock,
2400 })
2401 }
2402
2403 pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2404 NodeShutdownHandle {
2405 shared: Arc::clone(&self.shared),
2406 }
2407 }
2408
2409 #[cfg(feature = "fixture")]
2410 pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2411 &self,
2412 ) -> SpawnManagedWorktreeV2FailureProbe {
2413 self.shared
2414 .fixture_spawn_managed_worktree_v2_failure_probe
2415 .clone()
2416 }
2417
2418 pub fn install_environment_profile(
2423 &mut self,
2424 profile: NodeEnvironmentProfile,
2425 ) -> Result<(), NodeServerError> {
2426 if !self.shared.enabled_providers.contains(profile.provider()) {
2427 return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2428 profile.provider().clone(),
2429 ));
2430 }
2431 let (binding, native_profiles, materialization) = profile.into_parts();
2432 if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2433 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2434 }
2435 let mut profiles = self
2436 .shared
2437 .environment_profiles
2438 .write()
2439 .unwrap_or_else(|poisoned| poisoned.into_inner());
2440 if profiles.contains_key(&binding.id) {
2441 return Err(NodeServerError::DuplicateEnvironmentProfile(
2442 binding.id.clone(),
2443 ));
2444 }
2445 if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2446 return Err(NodeServerError::EnvironmentProfileCapacity {
2447 max: MAX_NODE_ENVIRONMENT_PROFILES,
2448 });
2449 }
2450 for native_id in binding.native_profile_ids() {
2451 if profiles.values().any(|current| {
2452 current.native_profile_ids().any(|current_id| current_id == native_id)
2453 }) {
2454 return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2455 native_id.to_string(),
2456 ));
2457 }
2458 }
2459
2460 let mut installed = Vec::with_capacity(native_profiles.len());
2461 for native_profile in native_profiles {
2462 let native_id = native_profile.id().clone();
2463 if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2464 for installed_id in installed.into_iter().rev() {
2465 let _ = self.runtime.remove_native_launch_profile(&installed_id);
2466 }
2467 return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2468 }
2469 installed.push(native_id);
2470 }
2471 if let Some(materialization) = materialization {
2472 self.shared
2473 .environment_materialization_profiles
2474 .write()
2475 .unwrap_or_else(|poisoned| poisoned.into_inner())
2476 .insert(binding.id.clone(), materialization);
2477 }
2478 profiles.insert(binding.id.clone(), binding);
2479 Ok(())
2480 }
2481
2482 pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2484 if self.shared.session_environment_materializer.is_none() {
2485 return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2486 }
2487 let mut catalog = self
2488 .shared
2489 .bundle_catalog
2490 .write()
2491 .unwrap_or_else(|poisoned| poisoned.into_inner());
2492 catalog
2493 .insert_idempotent(bundle)
2494 .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2495 Ok(())
2496 }
2497
2498 pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2499 let shutdown = self.shutdown_handle();
2500 let run = self.run();
2501 tokio::pin!(run);
2502 tokio::select! {
2503 result = &mut run => result,
2504 signal = wait_for_ctrl_signal() => {
2505 signal?;
2506 let shutdown_result = shutdown.request_shutdown().await;
2507 let run_result = run.await;
2508 shutdown_result.and(run_result)
2509 }
2510 }
2511 }
2512
2513 pub async fn run(self) -> Result<(), NodeServerError> {
2514 let NodeServer {
2515 config,
2516 mut runtime,
2517 shared,
2518 events,
2519 state_path_lock: _state_path_lock,
2520 } = self;
2521 shared.reconcile_materializations();
2522 shared.reconcile_managed_worktrees().await;
2523 shared.retire_unavailable_session_records().await;
2524 shared.reconcile_context_pack_exports().await;
2525 let endpoint = config.endpoint.clone();
2529 let call_home = config.call_home;
2530 let call_home_identity = config.call_home_identity.clone();
2531 let api_listen = config.api_listen;
2532 let bridge_listen = config.bridge_listen;
2533 let bridge_underlay = config.bridge_underlay;
2534 let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2535 .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2536 let accept_shared = Arc::clone(&shared);
2537 let api_shared = Arc::clone(&shared);
2538 let bridge_shared = Arc::clone(&shared);
2539 let shutdown_shared = Arc::clone(&shared);
2540 let shutdown_timeout = Duration::from_millis(
2541 config.runtime.provider_shutdown_timeout_ms.max(1),
2542 );
2543 let result = {
2544 let runtime_loop = drive_runtime_until_shutdown(
2545 &mut runtime,
2546 events,
2547 Arc::clone(&shared),
2548 shutdown_timeout,
2549 );
2550 let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2551 let api_loop = http_api::run(api_listen, api_shared);
2552 let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2553 tokio::pin!(runtime_loop);
2554 tokio::pin!(accept_loop);
2555 tokio::pin!(api_loop);
2556 tokio::pin!(bridge_loop);
2557 tokio::select! {
2558 runtime_result = &mut runtime_loop => {
2559 let shutdown_result = shutdown_shared
2560 .begin_shutdown()
2561 .await
2562 .map_err(NodeServerError::ShutdownDispatch);
2563 let (accept_result, api_result, bridge_result) =
2564 tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2565 match runtime_result {
2566 Err(error) => Err(error),
2567 Ok(()) => shutdown_result
2568 .and(accept_result)
2569 .and(api_result.map_err(NodeServerError::HttpApi))
2570 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2571 }
2572 }
2573 accept_result = &mut accept_loop => {
2574 let shutdown_result = shutdown_shared
2575 .begin_shutdown()
2576 .await
2577 .map_err(NodeServerError::ShutdownDispatch);
2578 let (runtime_result, api_result, bridge_result) =
2579 tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2580 match accept_result {
2581 Err(error) => Err(error),
2582 Ok(()) => shutdown_result
2583 .and(runtime_result)
2584 .and(api_result.map_err(NodeServerError::HttpApi))
2585 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2586 }
2587 }
2588 api_result = &mut api_loop => {
2589 let shutdown_result = shutdown_shared
2590 .begin_shutdown()
2591 .await
2592 .map_err(NodeServerError::ShutdownDispatch);
2593 let (runtime_result, accept_result, bridge_result) =
2594 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2595 match api_result {
2596 Err(error) => Err(NodeServerError::HttpApi(error)),
2597 Ok(()) => shutdown_result
2598 .and(runtime_result)
2599 .and(accept_result)
2600 .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2601 }
2602 }
2603 bridge_result = &mut bridge_loop => {
2604 let shutdown_result = shutdown_shared
2605 .begin_shutdown()
2606 .await
2607 .map_err(NodeServerError::ShutdownDispatch);
2608 let (runtime_result, accept_result, api_result) =
2609 tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2610 match bridge_result {
2611 Err(error) => Err(NodeServerError::BridgeApi(error)),
2612 Ok(()) => shutdown_result
2613 .and(runtime_result)
2614 .and(accept_result)
2615 .and(api_result.map_err(NodeServerError::HttpApi)),
2616 }
2617 }
2618 }
2619 };
2620 if let Ok(mut guard) = shared.delivery_store.lock() {
2627 *guard = None;
2628 }
2629 if let Ok(mut guard) = shared.context_pack_store.lock() {
2630 *guard = None;
2631 }
2632 result
2633 }
2634}
2635
2636async fn drive_runtime_until_shutdown(
2637 runtime: &mut NativeRuntime,
2638 events: EventSubscription,
2639 shared: Arc<NodeShared>,
2640 shutdown_timeout: Duration,
2641) -> Result<(), NodeServerError> {
2642 let _ = shared
2646 .shell_efficiency_profile
2647 .set(runtime.shell_efficiency_profile());
2648 let mut shutdown_started = None;
2649 let mut last_retention_sweep = Instant::now();
2650 let mut last_provisional_reap = Instant::now();
2651 loop {
2652 let runtime_tick_start = Instant::now();
2659 let tick_result = runtime.tick().await;
2660 let runtime_tick_elapsed = runtime_tick_start.elapsed();
2661
2662 let event_drain_start = Instant::now();
2663 for outcome in &tick_result.command_outcomes {
2671 if let Err(error) = &outcome.result {
2672 shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2673 tracing::warn!(
2674 command_id = outcome.command_id.0,
2675 cause = %error,
2676 "control command rejected"
2677 );
2678 shared.record_rejected_command(
2679 outcome.command_id,
2680 error.to_string(),
2681 error.is_unsupported_transport(),
2682 );
2683 }
2684 }
2685 let mut events_drained: u64 = 0;
2686 while let Ok(event) = events.try_recv() {
2687 events_drained += 1;
2688 let clean_exit = matches!(
2689 event.event,
2690 ControlEventKind::Exited { exit_code: Some(0), forced: false },
2691 );
2692 let instance_id = event.instance_id;
2693 let generation = event.generation;
2694 if clean_exit {
2706 if let Some((record_id, session)) =
2707 shared.managed_record_export_target_for_instance(instance_id, generation)
2708 {
2709 let export_shared = Arc::clone(&shared);
2710 tokio::spawn(async move {
2711 export_shared
2712 .reconcile_context_pack_export_for_record(&record_id, session)
2713 .await;
2714 });
2715 }
2716 }
2717 shared.publish_control(event);
2718 }
2719 let event_drain_elapsed = event_drain_start.elapsed();
2720
2721 let publish_terminal_frames_start = Instant::now();
2722 shared.publish_terminal_frames();
2723 let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2724
2725 shared
2731 .native_session_gauge
2732 .store(runtime.active_native_sessions(), Ordering::Relaxed);
2733 *shared
2734 .runtime_tick_profile
2735 .lock()
2736 .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2737
2738 let remainder_start = Instant::now();
2739 if shared.shutdown.load(Ordering::Acquire) {
2740 let started = *shutdown_started.get_or_insert_with(Instant::now);
2741 let snapshot = shared.handle.snapshot();
2742 let sessions_settled = snapshot.sessions.iter().all(|session| {
2743 !matches!(
2744 session.status,
2745 gate4agent_types::SessionStatus::Starting
2746 | gate4agent_types::SessionStatus::Running
2747 | gate4agent_types::SessionStatus::Stopping
2748 )
2749 });
2750 if runtime.active_native_sessions() == 0 && sessions_settled {
2751 return Ok(());
2752 }
2753 if started.elapsed() >= shutdown_timeout {
2754 return Err(NodeServerError::ShutdownTimedOut {
2755 active_native_sessions: runtime.active_native_sessions(),
2756 });
2757 }
2758 }
2759 let remainder_elapsed = remainder_start.elapsed();
2760
2761 {
2762 let mut profile = shared
2763 .drive_loop_profile
2764 .lock()
2765 .unwrap_or_else(|poisoned| poisoned.into_inner());
2766 profile.record_runtime_tick(runtime_tick_elapsed);
2767 profile.record_event_drain(event_drain_elapsed);
2768 profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2769 profile.record_remainder(remainder_elapsed);
2770 profile.note_iteration(Instant::now());
2771 }
2772
2773 shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2785 if tick_result.observations_applied == 0 && events_drained == 0 {
2786 shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2787 }
2788
2789 if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2794 shared.retire_unavailable_session_records().await;
2795 last_retention_sweep = Instant::now();
2796 }
2797 if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2799 for address in shared.take_due_provisional_spawns() {
2800 shared.reap_provisional_spawn(address).await;
2801 }
2802 last_provisional_reap = Instant::now();
2803 }
2804
2805 let hot = shared
2808 .pty_input_marks
2809 .lock()
2810 .unwrap_or_else(|poisoned| poisoned.into_inner())
2811 .values()
2812 .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2813 if hot {
2814 sleep(Duration::from_millis(1)).await;
2815 } else {
2816 tokio::select! {
2817 _ = sleep(Duration::from_millis(10)) => {}
2818 _ = shared.pty_input_wake.notified() => {}
2819 }
2820 }
2821 }
2822}
2823
2824#[derive(Clone)]
2825pub struct NodeShutdownHandle {
2826 shared: Arc<NodeShared>,
2827}
2828
2829impl NodeShutdownHandle {
2830 pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2831 self.shared
2832 .begin_shutdown()
2833 .await
2834 .map_err(NodeServerError::ShutdownDispatch)
2835 }
2836}
2837
2838async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2839 #[cfg(windows)]
2840 {
2841 let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2842 let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2843 return tokio::select! {
2844 signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2845 signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2846 };
2847 }
2848 #[cfg(unix)]
2849 {
2850 let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2851 let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2852 tokio::select! {
2853 signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2854 signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2855 }
2856 }
2857}
2858
2859fn shell_host_spec() -> AgentSpec {
2873 #[cfg(windows)]
2877 let (detect_command, program, fixed_args, process_name) = {
2878 let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2879 r"C:\Windows\System32\cmd.exe".to_owned()
2880 });
2881 ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2882 };
2883 #[cfg(not(windows))]
2884 let (detect_command, program, fixed_args, process_name) = (
2885 "bash".to_owned(),
2886 "/bin/bash".to_owned(),
2887 vec!["-i".to_owned()],
2888 "bash".to_owned(),
2889 );
2890 AgentSpec {
2891 id: AgentId::new("shell").expect("shell agent id"),
2892 revision: "hatchery:shell-pty-1".to_owned(),
2893 display_name: "Host shell".to_owned(),
2894 detection: DetectionSpec {
2895 command: detect_command,
2896 aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2897 required_commands: Vec::new(),
2898 unsupported_platforms: Vec::new(),
2899 },
2900 launch: LaunchSpec {
2901 program,
2902 fixed_args,
2903 },
2904 expected_processes: vec![ProcessMatcher::Exact {
2905 name: process_name,
2906 }],
2907 prompt: PromptSpec {
2908 initial: InitialPromptMode::None,
2909 native_draft: None,
2910 },
2911 readiness: AgentReadinessSpec::default(),
2912 capabilities: AgentCapabilities {
2913 agent_commands: None,
2914 transports: AgentTransportCapabilities {
2915 pty: true,
2916 pty_adapter: None,
2917 pipe: None,
2918 acp: None,
2919 },
2920 adapters: AgentAdapterCapabilities::default(),
2921 },
2922 verification: SpecVerification::Gate4AgentVerified,
2923 }
2924}
2925
2926fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2927 let mut specs = vec![shell_host_spec()];
2928 specs.extend(
2929 builtin_registry()
2930 .iter()
2931 .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2932 .cloned(),
2933 );
2934 AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2935}
2936
2937fn provider_contract_manifest(
2938 catalog: &AgentRegistry,
2939) -> Result<
2940 (
2941 Vec<ProviderContractSupport>,
2942 Vec<ProviderAdapterContractSupport>,
2943 ),
2944 NodeServerError,
2945> {
2946 let mut provider_contracts = Vec::new();
2947 let mut provider_adapter_contracts = Vec::new();
2948 for spec in catalog.iter() {
2949 let provider = spec.id.clone();
2950 let revision = ProviderContractRevision::new(spec.revision.clone())
2951 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2952 provider_contracts.push(ProviderContractSupport {
2953 provider: provider.clone(),
2954 revision,
2955 });
2956 for (family, binding) in declared_adapter_bindings(spec)? {
2957 let revision = AdapterContractRevision::new(binding.revision.clone())
2958 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2959 provider_adapter_contracts.push(ProviderAdapterContractSupport {
2960 provider: provider.clone(),
2961 family,
2962 adapter_id: binding.id.clone(),
2963 revision,
2964 });
2965 }
2966 }
2967 validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2968 .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969 Ok((provider_contracts, provider_adapter_contracts))
2970}
2971
2972fn declared_adapter_bindings(
2973 spec: &AgentSpec,
2974) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2975 let mut bindings = Vec::new();
2976 push_declared_binding(
2977 &mut bindings,
2978 AdapterFamily::PtySemantic,
2979 spec.capabilities.transports.pty_adapter.as_ref(),
2980 )?;
2981 push_declared_binding(
2982 &mut bindings,
2983 AdapterFamily::Pipe,
2984 spec.capabilities.adapters.pty_sidecar.as_ref(),
2985 )?;
2986 let pipe_transport = spec.capabilities.transports.pipe.as_ref();
2987 let pipe_family = pipe_transport.map(|transport| match transport.protocol {
2988 gate4agent_types::PipeProtocol::SemanticNdjson
2989 | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
2990 gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
2991 });
2992 push_declared_binding(
2993 &mut bindings,
2994 AdapterFamily::Pipe,
2995 pipe_transport
2996 .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
2997 .map(|transport| &transport.adapter),
2998 )?;
2999 push_declared_binding(
3000 &mut bindings,
3001 AdapterFamily::Acp,
3002 spec.capabilities
3003 .transports
3004 .acp
3005 .as_ref()
3006 .map(|transport| &transport.adapter),
3007 )?;
3008 push_declared_binding(
3009 &mut bindings,
3010 AdapterFamily::Hook,
3011 spec.capabilities.adapters.hook.as_ref(),
3012 )?;
3013 push_declared_binding(
3014 &mut bindings,
3015 AdapterFamily::ManagedHook,
3016 spec.capabilities.adapters.managed_hook.as_ref(),
3017 )?;
3018 push_declared_binding(
3019 &mut bindings,
3020 AdapterFamily::OneShot,
3021 pipe_transport
3022 .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3023 .map(|transport| &transport.adapter),
3024 )?;
3025 push_declared_binding(
3026 &mut bindings,
3027 AdapterFamily::OneShot,
3028 spec.capabilities.adapters.one_shot.as_ref(),
3029 )?;
3030 push_declared_binding(
3031 &mut bindings,
3032 AdapterFamily::History,
3033 spec.capabilities.adapters.history.as_ref(),
3034 )?;
3035 push_declared_binding(
3036 &mut bindings,
3037 AdapterFamily::Resume,
3038 spec.capabilities.adapters.resume.as_ref(),
3039 )?;
3040 push_declared_binding(
3041 &mut bindings,
3042 AdapterFamily::SessionOptions,
3043 spec.capabilities.adapters.session_options.as_ref(),
3044 )?;
3045 push_declared_binding(
3046 &mut bindings,
3047 AdapterFamily::CapabilityProbe,
3048 spec.capabilities.adapters.capability_probe.as_ref(),
3049 )?;
3050 Ok(bindings)
3051}
3052
3053fn push_declared_binding<'a>(
3054 bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3055 family: AdapterFamily,
3056 binding: Option<&'a AdapterBinding>,
3057) -> Result<(), NodeServerError> {
3058 let Some(binding) = binding else {
3059 return Ok(());
3060 };
3061 if let Some((_, existing)) = bindings
3062 .iter()
3063 .find(|(existing_family, _)| *existing_family == family)
3064 {
3065 if *existing == binding {
3066 return Ok(());
3067 }
3068 return Err(NodeServerError::ProviderContractManifest(format!(
3069 "provider declares conflicting {family:?} adapter bindings"
3070 )));
3071 }
3072 bindings.push((family, binding));
3073 Ok(())
3074}
3075
3076fn merge_durable_state(
3077 configured: &[WorkspaceConfig],
3078 mut loaded: LoadedNodeState,
3079) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3080 let mut workspaces = configured
3081 .iter()
3082 .cloned()
3083 .map(|workspace| (workspace.workspace_id.clone(), workspace))
3084 .collect::<BTreeMap<_, _>>();
3085 let mut roots = workspaces
3086 .values()
3087 .map(|workspace| {
3088 (
3089 platform::root_identity(&workspace.canonical_root),
3090 workspace.workspace_id.clone(),
3091 )
3092 })
3093 .collect::<BTreeMap<_, _>>();
3094 let mut warning = loaded
3095 .warning
3096 .take()
3097 .map(|message| session_registry::sanitized_persistence_summary(&message));
3098
3099 for (workspace_id, canonical_root) in loaded.workspaces {
3100 if let Some(existing) = workspaces.get(&workspace_id) {
3101 if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3102 return Err(NodeServerError::DurableState(io::Error::new(
3103 io::ErrorKind::InvalidData,
3104 DURABLE_STATE_CONFLICT_ERROR,
3105 )));
3106 }
3107 continue;
3108 }
3109 if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3110 return Err(NodeServerError::DurableState(io::Error::new(
3111 io::ErrorKind::InvalidData,
3112 DURABLE_STATE_CONFLICT_ERROR,
3113 )));
3114 }
3115 if !Path::new(&canonical_root).is_dir() {
3116 warning.get_or_insert_with(|| {
3117 session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3118 });
3119 continue;
3120 }
3121 let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3122 NodeServerError::DurableState(io::Error::new(
3123 io::ErrorKind::InvalidData,
3124 DURABLE_STATE_CONFLICT_ERROR,
3125 ))
3126 })?;
3127 roots.insert(
3128 platform::root_identity(&workspace.canonical_root),
3129 workspace.workspace_id.clone(),
3130 );
3131 workspaces.insert(workspace_id, workspace);
3132 }
3133
3134 for record in &mut loaded.records {
3135 match workspaces.get(&record.workspace_id) {
3136 Some(workspace)
3137 if platform::roots_equal(
3138 &workspace.canonical_root,
3139 windows_path_text(&record.canonical_root),
3140 ) => {}
3141 Some(_) => {
3142 return Err(NodeServerError::DurableState(io::Error::new(
3143 io::ErrorKind::InvalidData,
3144 DURABLE_STATE_CONFLICT_ERROR,
3145 )));
3146 }
3147 None => {
3148 record.state = ManagedSessionState::Unavailable;
3149 record.active_session = None;
3150 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3151 }
3152 }
3153 }
3154 Ok((workspaces.into_values().collect(), loaded.records, warning))
3155}
3156
3157struct RejectedCommandReason {
3160 message: String,
3161 unsupported_transport: bool,
3168 expires_at: Instant,
3169}
3170
3171struct NodeShared {
3172 handle: Gate4AgentHandle,
3173 access_token: String,
3174 node_id: NodeId,
3175 incarnation_id: NodeIncarnationId,
3176 started_at_unix_ms: u64,
3177 workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3178 worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3179 managed_worktree_profiles:
3180 BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3181 managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3182 enabled_providers: Vec<AgentId>,
3183 provider_runtime_statuses: ProviderRuntimeStatuses,
3184 provider_runtime_status_updates:
3185 RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3186 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3187 provider_contracts: Vec<ProviderContractSupport>,
3188 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3189 spawn_profiles: SpawnProfileRegistry,
3190 environment_profiles:
3191 RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3192 environment_materialization_profiles:
3193 RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3194 network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3202 #[cfg(feature = "dig2-station-probe")]
3206 browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3207 bundle_catalog: RwLock<BundleCatalog>,
3208 delivery_store: Mutex<Option<DeliveryStore>>,
3209 context_pack_store: Mutex<Option<ContextPackStore>>,
3210 context_catalog: RwLock<ContextPackCatalog>,
3211 native_launch_profile_control: Option<NativeLaunchProfileControl>,
3212 harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3213 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3214 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3215 m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3217 m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3219 m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3221 m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3223 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3225 #[cfg(unix)]
3228 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3229 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3230 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3231 managed_spawn_replays:
3232 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3233 controller: Mutex<Option<ControllerLease>>,
3234 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3237 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3239 history: Mutex<NodeEventHistory>,
3240 event_tx: broadcast::Sender<NodeEventEnvelope>,
3241 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3242 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3250 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3259 terminal_frame_watermarks:
3260 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3261 pty_input_wake: tokio::sync::Notify,
3263 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3266 next_connection_id: AtomicU64,
3267 next_instance_id: AtomicU64,
3268 next_command_id: AtomicU64,
3269 shutdown: AtomicBool,
3270 shutdown_notify: Notify,
3271 preauth_slots: Arc<Semaphore>,
3272 authenticated_slots: Arc<Semaphore>,
3273 inspection_slots: Arc<Semaphore>,
3274 mutation_gate: AsyncMutex<()>,
3275 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3276 session_records: Mutex<SessionRecords>,
3277 state_path: Option<PathBuf>,
3278 persistence_error: RwLock<Option<String>>,
3279 state_transaction: Mutex<()>,
3280 input_settle_timeout_ms: u64,
3281 drive_loop_profile: Mutex<DriveLoopProfiler>,
3285 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3291 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3303 native_session_gauge: AtomicUsize,
3310 connection_loop_iterations: AtomicU64,
3321 connection_events_sent: AtomicU64,
3322 rejected_commands_total: AtomicU64,
3331 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3351 drive_loop_iterations_total: AtomicU64,
3352 drive_loop_iterations_idle: AtomicU64,
3353 session_record_retention: SessionRecordRetentionConfig,
3357 retired_records_total: AtomicU64,
3363 #[cfg(feature = "fixture")]
3364 fixture_semantic_hook_policy: bool,
3365 #[cfg(feature = "fixture")]
3366 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3367}
3368
3369impl Drop for NodeShared {
3370 fn drop(&mut self) {
3371 SessionLifecycleHook::on_shutdown(self);
3372 }
3373}
3374
3375#[derive(Clone, Debug, Eq, PartialEq)]
3376struct SessionBinding {
3377 workspace_id: WorkspaceId,
3378 generation: SessionGeneration,
3379 runtime_policy: ProviderRuntimePolicy,
3380 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3381 record_id: Option<SessionRecordId>,
3382 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3383 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3384 bundle: Option<ResolvedBundleReceipt>,
3385 context: Option<ResolvedContextPackReceipt>,
3386 materialization_id: Option<MaterializationId>,
3387}
3388
3389#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3390enum SpawnRecordPolicy {
3391 ProviderIdentityOnly,
3392 Always,
3393}
3394
3395struct NativeEnvironmentSelectionGuard {
3396 control: Option<NativeLaunchProfileControl>,
3397 instance_id: AgentInstanceId,
3398}
3399
3400struct NativeInstanceOverlayGuard {
3401 control: Option<NativeLaunchProfileControl>,
3402 instance_id: AgentInstanceId,
3403}
3404
3405struct NativeHarnessMcpOverlayGuard {
3406 control: NativeLaunchProfileControl,
3407 instance_id: AgentInstanceId,
3408}
3409
3410impl NativeHarnessMcpOverlayGuard {
3411 fn retain(self) { std::mem::forget(self); }
3412}
3413
3414impl Drop for NativeHarnessMcpOverlayGuard {
3415 fn drop(&mut self) {
3416 self.control
3417 .clear_native_mcp_server_launch_overlay(self.instance_id);
3418 }
3419}
3420
3421impl NativeInstanceOverlayGuard {
3422 fn retain(mut self) {
3423 self.control = None;
3424 }
3425}
3426
3427impl Drop for NativeInstanceOverlayGuard {
3428 fn drop(&mut self) {
3429 if let Some(control) = self.control.as_ref() {
3430 control.clear_native_instance_launch_overlay(self.instance_id);
3431 }
3432 }
3433}
3434
3435enum PreparedNativeLaunchOverlay {
3436 Environment(NativeLaunchEnvironmentOverlay),
3437 Instance(NativeInstanceLaunchOverlay),
3438}
3439
3440impl NativeEnvironmentSelectionGuard {
3441 fn retain(mut self) {
3442 self.control = None;
3443 }
3444}
3445
3446impl Drop for NativeEnvironmentSelectionGuard {
3447 fn drop(&mut self) {
3448 if let Some(control) = self.control.as_ref() {
3449 control.clear_native_launch_profile_selection(self.instance_id);
3450 }
3451 }
3452}
3453
3454struct SessionMaterializationGuard<'a> {
3455 shared: &'a NodeShared,
3456 id: Option<MaterializationId>,
3457 provider_home: Option<PathBuf>,
3460}
3461
3462impl SessionMaterializationGuard<'_> {
3463 fn id(&self) -> Option<&MaterializationId> {
3464 self.id.as_ref()
3465 }
3466
3467 fn provider_home(&self) -> Option<&Path> {
3468 self.provider_home.as_deref()
3469 }
3470
3471 fn retain(mut self) {
3472 self.id = None;
3473 }
3474}
3475
3476impl Drop for SessionMaterializationGuard<'_> {
3477 fn drop(&mut self) {
3478 if let Some(id) = self.id.take() {
3479 let _ = self.shared.cleanup_materialization(&id);
3480 }
3481 }
3482}
3483
3484
3485trait SessionLifecycleHook {
3488 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3490 fn on_session_ended(&self, address: &SessionAddress);
3492 fn on_shutdown(&self);
3494 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3496 fn cleanup_session_materialization(
3498 &self,
3499 address: &SessionAddress,
3500 binding: &SessionBinding,
3501 ) -> Result<(), NodeFailure>;
3502}
3503
3504impl SessionLifecycleHook for NodeShared {
3505 fn on_session_ended(&self, address: &SessionAddress) {
3506 self.retire_session_grok_leader(address.session.instance_id);
3507 if let Some(accounts) = self.m4a_accounts.as_ref() {
3508 let instance = address.session.instance_id;
3509 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3510 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3511 accounts.remove_session_file(nick);
3512 }
3513 }
3514 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3515 registry.revoke_session(address);
3516 }
3517 if let Some(control) = self.native_launch_profile_control.as_ref() {
3518 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3519 }
3520 }
3521
3522 fn on_shutdown(&self) {
3523 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3524 .into_values()
3525 .collect();
3526 for socket in leaders {
3527 crate::grok_leader::retire(&socket);
3528 }
3529 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3530 let instances = registry.shutdown();
3531 if let Some(control) = self.native_launch_profile_control.as_ref() {
3532 for instance_id in instances {
3533 control.clear_native_mcp_server_launch_overlay(instance_id);
3534 }
3535 }
3536 }
3537 }
3538
3539 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3540 if let Some(control) = self.native_launch_profile_control.as_ref() {
3541 control.clear_native_mcp_server_launch_overlay(instance_id);
3542 }
3543 }
3544
3545 fn cleanup_session_materialization(
3546 &self,
3547 address: &SessionAddress,
3548 binding: &SessionBinding,
3549 ) -> Result<(), NodeFailure> {
3550 self.cleanup_session_owned_materialization(address, binding)
3551 }
3552
3553 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3554 #[cfg(feature = "dig2-station-probe")]
3557 {
3558 crate::dig2_station_lease::release_lease_for_holder(
3559 &self.browser_station_leases,
3560 address.session.instance_id,
3561 );
3562 }
3563 if let Some(control) = self.native_launch_profile_control.as_ref() {
3564 control.clear_native_mcp_server_launch_overlay(
3565 address.session.instance_id,
3566 );
3567 }
3568 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3569 registry.revoke_session(address);
3570 }
3571 if removed
3572 .is_some_and(|binding| {
3573 binding.environment_profile.is_some()
3574 || binding.bundle.is_some()
3575 || binding.context.is_some()
3576 })
3577 {
3578 if let Some(control) = self.native_launch_profile_control.as_ref() {
3579 control.clear_native_launch_profile_selection(
3580 address.session.instance_id,
3581 );
3582 control.clear_native_instance_launch_overlay(
3583 address.session.instance_id,
3584 );
3585 }
3586 }
3587 }
3588}
3589
3590struct SpawnPrepareRequest<'r> {
3591 instance_id: AgentInstanceId,
3592 address: &'r SessionAddress,
3593 provider: &'r AgentId,
3594 mode: SessionMode,
3595 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3596 bundle: Option<&'r ResolvedBundleReceipt>,
3597 context: Option<&'r ResolvedContextPackReceipt>,
3598 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3599 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3600 approval_level: ApprovalLevel,
3601 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3602 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3603}
3604
3605struct PreparedSpawn<'a> {
3609 instance_overlay: Option<NativeInstanceOverlayGuard>,
3610 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3611 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3612 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3613 #[cfg(feature = "dig2-station-probe")]
3614 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3615 materialization_id: Option<MaterializationId>,
3616}
3617
3618impl PreparedSpawn<'_> {
3619 fn retain_session_guards(&mut self) {
3621 if let Some(selection) = self.environment_selection.take() {
3622 selection.retain();
3623 }
3624 if let Some(overlay) = self.instance_overlay.take() {
3625 overlay.retain();
3626 }
3627 if let Some(guard) = self.materialization_guard.take() {
3628 guard.retain();
3629 }
3630 }
3631
3632 fn retain_launch_guards(&mut self) {
3634 if let Some(overlay) = self.harness_mcp_overlay.take() {
3635 overlay.retain();
3636 }
3637 #[cfg(feature = "dig2-station-probe")]
3638 if let Some(guard) = self.browser_station_lease_guard.take() {
3639 guard.retain();
3640 }
3641 }
3642}
3643
3644trait SpawnPreparer {
3648 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3649 fn select_environment(
3651 &self,
3652 instance_id: AgentInstanceId,
3653 provider: &AgentId,
3654 mode: SessionMode,
3655 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3656 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3657 fn install_launch_overlay(
3659 &self,
3660 instance_id: AgentInstanceId,
3661 overlay: PreparedNativeLaunchOverlay,
3662 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3663}
3664
3665impl NodeShared {
3666 fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3669 let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3670 let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3671 self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3672 key
3673 }
3674
3675 fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3676 let accounts = self.m4a_accounts.as_ref()?;
3677 let nick = self.m4a_nick_of(instance_id)?;
3678 match accounts.session_workdir(root, provider.as_str(), &nick) {
3679 Ok(dir) => dir,
3680 Err(error) => {
3681 tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3682 None
3683 }
3684 }
3685 }
3686
3687 fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3688 self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3689 }
3690
3691 fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3694 let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3695 for record in records.records.values_mut() {
3696 if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3697 record.display_name = nick.to_owned();
3698 }
3699 }
3700 }
3701
3702 fn m4a_session_overlay(
3705 &self,
3706 accounts: &crate::m4a_accounts::M4aSessionAccounts,
3707 m4a_provider: &str,
3708 provider: &AgentId,
3709 instance_id: AgentInstanceId,
3710 environment_overlay: Option<PreparedNativeLaunchOverlay>,
3711 session_key: &str,
3712 ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3713 let session_key = session_key.to_owned();
3714 Ok( match accounts.provision(m4a_provider, &session_key) {
3715 Ok(account) => {
3716 let mut identity = account.env_mutations();
3717 identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3718 let merged = match environment_overlay {
3719 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3720 .map(PreparedNativeLaunchOverlay::Instance),
3721 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3722 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3723 }
3724 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3725 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3726 }
3727 }
3728 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3729 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3730 let mut merged = merged;
3731 match accounts.write_session_file(&account) {
3732 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3733 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3734 Ok(args) if !args.is_empty() => {
3735 merged = match merged {
3736 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3737 .append_extra_args(args)
3738 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3739 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3740 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3741 .into_instance_with_extra_args(args)
3742 .map(PreparedNativeLaunchOverlay::Instance)
3743 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3744 };
3745 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3746 }
3747 Ok(_) => {}
3748 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3749 },
3750 }
3751 self.m4a_live
3752 .lock()
3753 .unwrap_or_else(|p| p.into_inner())
3754 .insert(account.nick.clone(), (instance_id, provider.clone()));
3755 self.name_record_after_nick(instance_id, &account.nick);
3756 tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3757 crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3759 Some(merged)
3760 }
3761 Err(error) => {
3762 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3763 environment_overlay
3764 }
3765 })
3766 }
3767}
3768
3769impl SpawnPreparer for NodeShared {
3770 fn select_environment(
3771 &self,
3772 instance_id: AgentInstanceId,
3773 provider: &AgentId,
3774 mode: SessionMode,
3775 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3776 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3777 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3778 }
3779
3780 fn install_launch_overlay(
3781 &self,
3782 instance_id: AgentInstanceId,
3783 overlay: PreparedNativeLaunchOverlay,
3784 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3785 self.install_prepared_launch_overlay(instance_id, overlay)
3786 }
3787
3788 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3789 let SpawnPrepareRequest {
3790 instance_id,
3791 address,
3792 provider,
3793 mode,
3794 environment_profile,
3795 bundle,
3796 context,
3797 managed_authority,
3798 harness_mcp,
3799 approval_level,
3800 network_allowlist,
3801 browser_profile_id,
3802 } = request;
3803 #[cfg(feature = "dig2-station-probe")]
3808 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3809 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3810 match acquire_lease(
3811 &self.browser_station_leases,
3812 profile_id,
3813 instance_id,
3814 unix_time_ms(),
3815 ) {
3816 Ok(guard) => Some(guard),
3817 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3818 return Err(failure(
3819 NodeFailureCode::BrowserStationProfileBusy,
3820 "dig2browser station profile is already leased by another session",
3821 ));
3822 }
3823 Err(BrowserStationLeaseError::CapacityExceeded) => {
3824 return Err(failure(
3825 NodeFailureCode::BrowserStationProfileBusy,
3826 "dig2browser station profile lease capacity exhausted",
3827 ));
3828 }
3829 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3830 return Err(failure(
3831 NodeFailureCode::BrowserStationProfileBusy,
3832 "session already holds a dig2browser station profile lease",
3833 ));
3834 }
3835 }
3836 } else {
3837 None
3838 };
3839 #[cfg(not(feature = "dig2-station-probe"))]
3840 let _ = browser_profile_id;
3841 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3842 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3851 {
3852 return Err(failure(
3853 NodeFailureCode::BindingMismatch,
3854 "harness MCP reservation does not match the exact PTY/ACP provider",
3855 ));
3856 }
3857 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3858 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3859 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3860 })?;
3861 let launch = prepared.launch();
3866 let mut env_entries = vec![
3867 (
3868 OsString::from(launch.endpoint_env.as_str()),
3869 prepared.endpoint().as_os_str().to_os_string(),
3870 ),
3871 (
3872 OsString::from(launch.token_env.as_str()),
3873 prepared.token().expose().into(),
3874 ),
3875 (
3876 OsString::from(launch.program_env.as_str()),
3877 prepared.helper_program().as_os_str().to_os_string(),
3878 ),
3879 ];
3880 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3881 env_entries.push(trace_entry);
3882 }
3883 if let Some(door) = crate::door::door_listen_addr() {
3884 env_entries.push((
3885 OsString::from("HATCHERY_NODE_DOOR"),
3886 OsString::from(door.to_string()),
3887 ));
3888 let session_key = format!(
3892 "pty-{}-{}-{}",
3893 self.incarnation_id,
3894 address.workspace_id.as_str(),
3895 address.session.instance_id.0
3896 );
3897 env_entries.push((
3898 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3899 OsString::from(session_key),
3900 ));
3901 }
3902 let overlay = NativeMcpServerLaunchOverlay::new(
3903 provider.clone(),
3904 launch.server_name.as_str(),
3905 prepared.helper_program().as_os_str().to_os_string(),
3906 launch.args.iter().map(OsString::from).collect(),
3907 env_entries,
3908 launch.scrub_env.iter().map(OsString::from).collect(),
3909 ).map_err(|_| failure(
3910 NodeFailureCode::HarnessMcpUnavailable,
3911 "harness MCP launch overlay is unavailable",
3912 ))?;
3913 control
3914 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3915 .map_err(|_| failure(
3916 NodeFailureCode::HarnessMcpUnavailable,
3917 "harness MCP launch overlay could not be installed",
3918 ))?;
3919 tracing::info!(
3925 node_id = %self.node_id,
3926 instance_id = ?instance_id,
3927 provider = %provider,
3928 mode = ?mode,
3929 "harness MCP overlay installed for spawn",
3930 );
3931 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3932 } else {
3933 None
3934 };
3935 let prepared_materialization = self.prepare_session_materialization(
3936 &address,
3937 &provider,
3938 mode,
3939 environment_profile,
3940 bundle,
3941 context,
3942 managed_authority.cloned(),
3943 )?;
3944 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3945 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3946 None => (None, None),
3947 };
3948 let materialization_id = materialization_guard
3949 .as_ref()
3950 .and_then(SessionMaterializationGuard::id)
3951 .cloned();
3952 let mut environment_selection = SpawnPreparer::select_environment(self,
3953 instance_id,
3954 &provider,
3955 mode,
3956 environment_profile,
3957 )?;
3958 let provider_home = materialization_guard
3961 .as_ref()
3962 .and_then(SessionMaterializationGuard::provider_home);
3963 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3964 &provider,
3965 mode,
3966 approval_level,
3967 network_allowlist,
3968 provider_home,
3969 )?;
3970 let mut network_overlay_args = network_overlay_args;
3971 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
3972 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
3973 }
3974 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3975 (prepared, args) if args.is_empty() => prepared,
3976 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3977 overlay.append_extra_args(args).map_err(|_| {
3978 failure(
3979 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3980 "network allowlist overlay could not merge with instance launch args",
3981 )
3982 })?;
3983 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3984 }
3985 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3986 Some(PreparedNativeLaunchOverlay::Instance(
3987 overlay
3988 .into_instance_with_extra_args(args)
3989 .map_err(|_| {
3990 failure(
3991 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3992 "network allowlist overlay could not merge with environment overlay",
3993 )
3994 })?,
3995 ))
3996 }
3997 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3998 NativeInstanceLaunchOverlay::new(
3999 provider.clone(),
4000 match mode {
4001 SessionMode::Pty => TransportKind::Pty,
4002 SessionMode::Acp => TransportKind::Acp,
4003 SessionMode::Inline => TransportKind::Pipe,
4004 },
4005 Vec::new(),
4006 args,
4007 )
4008 .map_err(|_| {
4009 failure(
4010 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4011 "network allowlist overlay is invalid for this transport",
4012 )
4013 })?,
4014 )),
4015 };
4016 #[cfg(unix)]
4019 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4021 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4022 });
4023 #[cfg(unix)]
4024 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4025 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4026 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4027 let uid = unsafe { libc_geteuid() };
4028 let handle = proxy.reserve(&session_key, m4a_provider, uid);
4029 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4030 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4031 Some(vec![
4032 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4033 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4034 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4035 ])
4036 }
4037 _ => None,
4038 };
4039 #[cfg(not(unix))]
4040 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4041 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4042 (SessionMode::Pty, _, _, Some(identity)) => {
4043 let merged = match environment_overlay {
4044 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4045 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4046 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4047 }
4048 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4049 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4050 }
4051 }
4052 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4053 Some(merged)
4054 }
4055 (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4056 let session_key = self.m4a_session_key(instance_id);
4057 self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key)?
4058 }
4059 (SessionMode::Pty, Some(_), None, _) => {
4060 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4061 environment_overlay
4062 }
4063 _ => environment_overlay,
4064 };
4065 let mut instance_overlay = environment_overlay
4066 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4067 .transpose()?
4068 .flatten();
4069 Ok(PreparedSpawn {
4070 instance_overlay,
4071 environment_selection,
4072 materialization_guard,
4073 harness_mcp_overlay,
4074 #[cfg(feature = "dig2-station-probe")]
4075 browser_station_lease_guard,
4076 materialization_id,
4077 })
4078 }
4079}
4080
4081struct SessionRecords {
4082 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4083}
4084
4085struct SpawnIdempotencyCache {
4086 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4087}
4088
4089struct SpawnIdempotencyEntry {
4090 value: SpawnIdempotencyValue,
4091 expires_at: Instant,
4092}
4093
4094enum SpawnIdempotencyValue {
4095 Standard {
4096 spec: SpawnSpec,
4097 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4098 },
4099 Managed {
4100 request: ManagedWorktreeSpawnRequest,
4101 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4102 },
4103 HarnessMcp,
4104}
4105
4106enum DurableManagedSpawnReplayDecision {
4107 Reserved,
4108 PendingLinked,
4109 Committed(ManagedWorktreeSpawnReceipt),
4110}
4111
4112impl SpawnIdempotencyValue {
4113 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4114 match self {
4115 Self::Standard { result, .. } => result
4116 .as_ref()
4117 .ok()
4118 .and_then(|receipt| receipt.context.as_ref()),
4119 Self::Managed { result, .. } => result
4120 .as_ref()
4121 .ok()
4122 .and_then(|receipt| receipt.spawn.context.as_ref()),
4123 Self::HarnessMcp => None,
4124 }
4125 .is_some_and(|receipt| &receipt.id == context_id)
4126 }
4127}
4128
4129#[derive(Clone, Copy)]
4130struct ControllerLease {
4131 connection_id: u64,
4132 expires_at: Instant,
4133}
4134
4135struct NodeEventHistory {
4136 last_sequence: u64,
4137 replay_floor_sequence: u64,
4138 events: VecDeque<NodeEventEnvelope>,
4139 record_providers: BTreeMap<SessionRecordId, AgentId>,
4140 removed_record_providers: BTreeMap<u64, AgentId>,
4141}
4142
4143impl NodeEventHistory {
4144 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4145 Self {
4146 last_sequence: 0,
4147 replay_floor_sequence: 1,
4148 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4149 record_providers,
4150 removed_record_providers: BTreeMap::new(),
4151 }
4152 }
4153}
4154
4155impl NodeShared {
4156 fn session_grok_leader_socket(
4160 &self,
4161 provider: &AgentId,
4162 mode: SessionMode,
4163 instance_id: AgentInstanceId,
4164 ) -> Option<std::path::PathBuf> {
4165 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4166 return None;
4167 }
4168 let root = self
4169 .m4a_accounts
4170 .as_ref()
4171 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4172 .unwrap_or_else(std::env::temp_dir);
4173 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4174 if let Some(previous) = self
4175 .grok_leaders
4176 .lock()
4177 .unwrap_or_else(|p| p.into_inner())
4178 .insert(instance_id, socket.clone())
4179 {
4180 crate::grok_leader::retire(&previous);
4181 }
4182 Some(socket)
4183 }
4184
4185 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4187 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4188 if let Some(socket) = socket {
4189 crate::grok_leader::retire(&socket);
4190 }
4191 }
4192
4193 fn new_with_incarnation(
4194 handle: Gate4AgentHandle,
4195 access_token: String,
4196 node_id: NodeId,
4197 incarnation_id: NodeIncarnationId,
4198 workspaces: Vec<WorkspaceConfig>,
4199 enabled_providers: Vec<AgentId>,
4200 provider_runtime_statuses: ProviderRuntimeStatuses,
4201 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4202 provider_contracts: Vec<ProviderContractSupport>,
4203 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4204 spawn_profiles: SpawnProfileRegistry,
4205 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4206 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4207 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4208 state_path: Option<PathBuf>,
4209 records: Vec<ManagedSessionRecord>,
4210 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4211 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4212 materializations: Vec<MaterializationOwnershipRecord>,
4213 persistence_warning: Option<String>,
4214 input_settle_timeout_ms: u64,
4215 ) -> Self {
4216 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4217 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4218 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4219 let (harness_mcp_proxy_event_tx, _) =
4220 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4221 let record_providers = records
4222 .iter()
4223 .map(|record| (record.record_id.clone(), record.provider.clone()))
4224 .collect();
4225 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4226 .iter()
4227 .map(|workspace| {
4228 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4229 })
4230 .collect();
4231 for lease in &managed_worktrees {
4232 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4233 }
4234 let managed_worktree_profiles = workspaces.iter()
4235 .map(|workspace| (
4236 workspace.workspace_id.clone(),
4237 workspace.managed_worktree_profiles.clone(),
4238 ))
4239 .collect();
4240 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4241 managed_worktrees,
4242 managed_worktree_tombstones,
4243 ).expect("durable managed worktree registry was validated while loading");
4244 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4245 managed_worktrees.reattach_record_holders(
4246 &managed_worktree_record_holders(&records),
4247 unix_time_ms(),
4248 );
4249 Self {
4250 handle,
4251 access_token,
4252 node_id,
4253 incarnation_id,
4254 started_at_unix_ms: std::time::SystemTime::now()
4255 .duration_since(std::time::UNIX_EPOCH)
4256 .unwrap_or_default()
4257 .as_millis()
4258 .min(u64::MAX as u128) as u64,
4259 workspaces: RwLock::new(
4260 workspaces
4261 .into_iter()
4262 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4263 .collect(),
4264 ),
4265 worktree_service_modes,
4266 managed_worktree_profiles,
4267 managed_worktrees: Mutex::new(managed_worktrees),
4268 enabled_providers,
4269 provider_runtime_statuses,
4270 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4271 provider_runtime_monitor,
4272 provider_contracts,
4273 provider_adapter_contracts,
4274 spawn_profiles,
4275 environment_profiles: RwLock::new(BTreeMap::new()),
4276 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4277 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4278 #[cfg(feature = "dig2-station-probe")]
4279 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4280 bundle_catalog: RwLock::new(BundleCatalog::default()),
4281 delivery_store: Mutex::new(None),
4282 context_pack_store: Mutex::new(None),
4283 context_catalog: RwLock::new(ContextPackCatalog::default()),
4284 native_launch_profile_control,
4285 harness_mcp_registry: None,
4286 native_session_catalog,
4287 session_environment_materializer,
4288 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4289 m4a_live: Mutex::new(BTreeMap::new()),
4290 m4a_keys: Mutex::new(BTreeMap::new()),
4291 m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4292 grok_leaders: Mutex::new(BTreeMap::new()),
4293 #[cfg(unix)]
4294 signer_proxy: std::sync::OnceLock::new(),
4295 materializations: Mutex::new(
4296 materializations
4297 .into_iter()
4298 .map(|record| (record.id().clone(), record))
4299 .collect(),
4300 ),
4301 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4302 entries: BTreeMap::new(),
4303 }),
4304 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4305 controller: Mutex::new(None),
4306 provisional_spawns: Mutex::new(BTreeMap::new()),
4307 pending_acp_options: Mutex::new(BTreeMap::new()),
4308 history: Mutex::new(NodeEventHistory::new(record_providers)),
4309 event_tx,
4310 terminal_event_tx,
4311 agent_stream_event_tx,
4312 harness_mcp_proxy_event_tx,
4313 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4314 pty_input_wake: tokio::sync::Notify::new(),
4315 pty_input_marks: Mutex::new(BTreeMap::new()),
4316 next_connection_id: AtomicU64::new(1),
4317 next_instance_id: AtomicU64::new(1),
4318 next_command_id: AtomicU64::new(1),
4319 shutdown: AtomicBool::new(false),
4320 shutdown_notify: Notify::new(),
4321 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4322 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4323 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4324 mutation_gate: AsyncMutex::new(()),
4325 session_bindings: Mutex::new(BTreeMap::new()),
4326 session_records: Mutex::new(SessionRecords {
4327 records: records
4328 .into_iter()
4329 .map(|record| (record.record_id.clone(), record))
4330 .collect(),
4331 }),
4332 state_path,
4333 persistence_error: RwLock::new(
4334 persistence_warning
4335 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4336 ),
4337 state_transaction: Mutex::new(()),
4338 input_settle_timeout_ms,
4339 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4340 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4341 shell_efficiency_profile: OnceLock::new(),
4342 native_session_gauge: AtomicUsize::new(0),
4343 connection_loop_iterations: AtomicU64::new(0),
4344 connection_events_sent: AtomicU64::new(0),
4345 rejected_commands_total: AtomicU64::new(0),
4346 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4347 drive_loop_iterations_total: AtomicU64::new(0),
4348 drive_loop_iterations_idle: AtomicU64::new(0),
4349 session_record_retention: SessionRecordRetentionConfig::default(),
4350 retired_records_total: AtomicU64::new(0),
4351 #[cfg(feature = "fixture")]
4352 fixture_semantic_hook_policy: false,
4353 #[cfg(feature = "fixture")]
4354 fixture_spawn_managed_worktree_v2_failure_probe:
4355 SpawnManagedWorktreeV2FailureProbe::default(),
4356 }
4357 }
4358
4359 #[cfg(test)]
4360 fn new(
4361 handle: Gate4AgentHandle,
4362 access_token: String,
4363 node_id: NodeId,
4364 workspaces: Vec<WorkspaceConfig>,
4365 enabled_providers: Vec<AgentId>,
4366 ) -> Self {
4367 Self::new_with_incarnation(
4368 handle,
4369 access_token,
4370 node_id,
4371 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4372 workspaces,
4373 enabled_providers,
4374 ProviderRuntimeStatuses::default(),
4375 None,
4376 Vec::new(),
4377 Vec::new(),
4378 SpawnProfileRegistry::default(),
4379 None,
4380 None,
4381 None,
4382 None,
4383 Vec::new(),
4384 Vec::new(),
4385 Vec::new(),
4386 Vec::new(),
4387 None,
4388 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4389 )
4390 }
4391
4392 fn persistence_error(&self) -> Option<String> {
4393 self.persistence_error
4394 .read()
4395 .unwrap_or_else(|poisoned| poisoned.into_inner())
4396 .clone()
4397 }
4398
4399 async fn admit_provider_runtime(
4400 &self,
4401 provider: &AgentId,
4402 requirement: ProviderRuntimeRequirement,
4403 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4404 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4405 let pipe_transport = monitor.supports_pipe_transport(provider);
4406 let acp_transport = monitor.supports_acp_transport(provider);
4407 let provider = provider.clone();
4408 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4409 let (status, admission) = timeout(
4410 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4411 refresh,
4412 )
4413 .await
4414 .map_err(|_| failure(
4415 NodeFailureCode::BackendBusy,
4416 "provider runtime probe exceeded its bounded deadline",
4417 ))?
4418 .map_err(|_| failure(
4419 NodeFailureCode::BackendOperationFailed,
4420 "provider runtime probe task failed",
4421 ))?;
4422 if let Some(status) = status {
4423 self
4424 .provider_runtime_status_updates
4425 .write()
4426 .unwrap_or_else(|poisoned| poisoned.into_inner())
4427 .insert(status.provider().clone(), status);
4428 }
4429 admission.and_then(|policy| {
4430 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4431 })
4432 } else {
4433 crate::provider_runtime::admit_status(
4439 &self.provider_runtime_statuses,
4440 provider,
4441 false,
4442 false,
4443 requirement,
4444 )
4445 };
4446 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4447 admission.map_err(|error| match error {
4448 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4449 NodeFailureCode::BackendOperationFailed,
4450 "provider launcher is unavailable",
4451 ),
4452 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4453 NodeFailureCode::UnsupportedCapability,
4454 "provider semantic capability is not verified",
4455 ),
4456 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4457 NodeFailureCode::BackendBusy,
4458 "provider runtime probe is already in progress",
4459 ),
4460 })
4461 }
4462
4463 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4469 match self.provider_runtime_monitor.as_deref() {
4470 Some(monitor) => monitor.supports_pipe_transport(provider),
4471 None => false,
4472 }
4473 }
4474
4475 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4481 match self.provider_runtime_monitor.as_deref() {
4482 Some(monitor) => monitor.supports_acp_transport(provider),
4483 None => false,
4484 }
4485 }
4486
4487 fn admit_declared_hook_semantics(
4503 &self,
4504 provider: &AgentId,
4505 policy: ProviderRuntimePolicy,
4506 ) -> ProviderRuntimePolicy {
4507 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4508 return policy;
4509 }
4510 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4511 contract.provider == *provider && contract.family == AdapterFamily::Hook
4512 });
4513 if !declares_hook {
4514 return policy;
4515 }
4516 ProviderRuntimePolicy::new(
4517 policy.raw_pty_lifecycle,
4518 policy.semantic_readiness,
4519 policy.structured_prompt,
4520 policy.provider_session_identity,
4521 policy.semantic_resume,
4522 true,
4523 )
4524 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4525 }
4526
4527 fn resolve_network_allowlist_overlay_args(
4542 &self,
4543 provider: &AgentId,
4544 mode: SessionMode,
4545 approval_level: ApprovalLevel,
4546 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4547 provider_home: Option<&Path>,
4548 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4549 let Some(allowlist_id) = network_allowlist else {
4550 return Ok(Vec::new());
4551 };
4552 let catalog = self
4553 .network_allowlist_catalog
4554 .read()
4555 .unwrap_or_else(|poisoned| poisoned.into_inner());
4556 let Some(entry) = catalog.get(allowlist_id) else {
4557 return Err(failure(
4558 NodeFailureCode::UnknownNetworkAllowlist,
4559 "spawn network allowlist is unavailable on this node",
4560 ));
4561 };
4562
4563 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4565 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4566 |message| {
4567 failure(
4568 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4569 &message,
4570 )
4571 },
4572 )?;
4573 if !matches!(mode, SessionMode::Pty) {
4574 return Err(failure(
4575 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4576 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4577 ));
4578 }
4579 let Some(home) = provider_home else {
4580 return Err(failure(
4581 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4582 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4583 ));
4584 };
4585 let domains =
4586 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4587 if domains.is_empty() {
4588 return Err(failure(
4589 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4590 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4591 ));
4592 }
4593 let settings_path = home.join(
4594 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4595 );
4596 let json =
4597 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4598 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4599 |_| {
4600 failure(
4601 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4602 "Claude station network settings sidecar could not be written under ProviderHome",
4603 )
4604 },
4605 )?;
4606 let overlay =
4607 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4608 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4609 }
4610
4611 let Some(native) = entry.provider_native.as_ref() else {
4612 return Ok(Vec::new());
4613 };
4614 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4615 provider.as_str(),
4616 approval_level,
4617 native,
4618 )
4619 .map_err(|message| {
4620 failure(
4621 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4622 &message,
4623 )
4624 })?;
4625 if overlay.is_empty() {
4626 return Ok(Vec::new());
4627 }
4628 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4629 return Err(failure(
4630 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4631 "Codex network_access -c overlay requires PTY or ACP transport",
4632 ));
4633 }
4634 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4635 }
4636
4637 fn resolve_codex_network_access_overlay_args(
4639 &self,
4640 provider: &AgentId,
4641 mode: SessionMode,
4642 approval_level: ApprovalLevel,
4643 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4644 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4645 self.resolve_network_allowlist_overlay_args(
4646 provider,
4647 mode,
4648 approval_level,
4649 network_allowlist,
4650 None,
4651 )
4652 }
4653
4654 fn resolve_environment_profile(
4655 &self,
4656 resolved: &ResolvedSpawnSpec,
4657 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4658 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4664 let catalog = self
4665 .network_allowlist_catalog
4666 .read()
4667 .unwrap_or_else(|poisoned| poisoned.into_inner());
4668 let Some(entry) = catalog.get(allowlist_id) else {
4669 return Err(failure(
4670 NodeFailureCode::UnknownNetworkAllowlist,
4671 "spawn network allowlist is unavailable on this node",
4672 ));
4673 };
4674 if let Some(native) = entry.provider_native.as_ref() {
4677 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4678 resolved.provider.as_str(),
4679 resolved.approval_level,
4680 native,
4681 ) {
4682 return Err(failure(
4683 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4684 &message,
4685 ));
4686 }
4687 }
4688 }
4689 #[cfg(feature = "dig2-station-probe")]
4698 if resolved.browser_profile_id.is_some() {
4699 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4700 match probe_station_reachable() {
4701 Ok(()) => {}
4702 Err(StationProbeError::UnsupportedPlatform)
4703 | Err(StationProbeError::InvalidPipeSuffix) => {
4704 return Err(failure(
4705 NodeFailureCode::BrowserStationProbeUnavailable,
4706 "dig2browser station probe unavailable on this platform",
4707 ));
4708 }
4709 Err(StationProbeError::Unreachable) => {
4710 return Err(failure(
4711 NodeFailureCode::BrowserStationUnreachable,
4712 "dig2browser station named-pipe is unreachable",
4713 ));
4714 }
4715 }
4716 }
4717 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4718 return Ok(None);
4719 };
4720 let profiles = self
4721 .environment_profiles
4722 .read()
4723 .unwrap_or_else(|poisoned| poisoned.into_inner());
4724 let binding = profiles.get(profile_id).ok_or_else(|| {
4725 failure(
4726 NodeFailureCode::UnknownEnvironmentProfile,
4727 "spawn environment profile is unavailable on this node",
4728 )
4729 })?;
4730 if binding.provider != resolved.provider
4731 || binding.native_profile_id(resolved.mode).is_none()
4732 {
4733 return Err(failure(
4734 NodeFailureCode::EnvironmentProfileBindingMismatch,
4735 "spawn environment profile does not match the provider and mode",
4736 ));
4737 }
4738 Ok(Some(ResolvedEnvironmentProfileReceipt {
4742 profile_id: binding.id.clone(),
4743 profile_revision: binding.revision.clone(),
4744 network_allowlist: resolved.network_allowlist.clone(),
4745 browser_profile_id: resolved.browser_profile_id.clone(),
4746 }))
4747 }
4748
4749 fn resolve_bundle(
4750 &self,
4751 resolved: &ResolvedSpawnSpec,
4752 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4753 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4754 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4755 return Ok(None);
4756 };
4757 let catalog = self.bundle_catalog
4758 .read()
4759 .unwrap_or_else(|poisoned| poisoned.into_inner());
4760 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4761 failure(
4762 NodeFailureCode::UnknownBundle,
4763 "spawn bundle is unavailable on this node",
4764 )
4765 })?;
4766 self.bundle_layout(
4767 &resolved.provider,
4768 resolved.mode,
4769 environment_profile,
4770 bundle,
4771 )?;
4772 Ok(Some(bundle.receipt()))
4773 }
4774
4775 fn begin_delivery_stage(
4776 &self,
4777 manifest: DeliveryBundleManifestV2,
4778 ) -> Result<NodeResponse, NodeFailure> {
4779 let mut store = self
4780 .delivery_store
4781 .lock()
4782 .unwrap_or_else(|poisoned| poisoned.into_inner());
4783 let store = store.as_mut().ok_or_else(|| {
4784 failure(
4785 NodeFailureCode::DeliveryStageStorageFailed,
4786 "node delivery store is unavailable",
4787 )
4788 })?;
4789 let begun = store.begin(manifest).map_err(delivery_failure)?;
4790 Ok(NodeResponse::DeliveryStageBegun {
4791 stage_id: begun.stage_id,
4792 manifest_digest: begun.manifest_digest,
4793 missing_blobs: begun.missing_blobs,
4794 })
4795 }
4796
4797 fn put_delivery_blob_chunk(
4798 &self,
4799 stage_id: DeliveryStageId,
4800 blob_digest: DeliveryBlobDigestV1,
4801 offset: u64,
4802 chunk_hex: DeliveryBlobChunkHexV1,
4803 ) -> Result<NodeResponse, NodeFailure> {
4804 let mut store = self
4805 .delivery_store
4806 .lock()
4807 .unwrap_or_else(|poisoned| poisoned.into_inner());
4808 let store = store.as_mut().ok_or_else(|| {
4809 failure(
4810 NodeFailureCode::DeliveryStageStorageFailed,
4811 "node delivery store is unavailable",
4812 )
4813 })?;
4814 let accepted = store
4815 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4816 .map_err(delivery_failure)?;
4817 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4818 stage_id: accepted.stage_id,
4819 blob_digest: accepted.blob_digest,
4820 next_offset: accepted.next_offset,
4821 })
4822 }
4823
4824 fn commit_delivery_stage(
4825 &self,
4826 stage_id: DeliveryStageId,
4827 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4828 let mut store = self
4829 .delivery_store
4830 .lock()
4831 .unwrap_or_else(|poisoned| poisoned.into_inner());
4832 let store = store.as_mut().ok_or_else(|| {
4833 failure(
4834 NodeFailureCode::DeliveryStageStorageFailed,
4835 "node delivery store is unavailable",
4836 )
4837 })?;
4838 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4839 let mut catalog = self
4840 .bundle_catalog
4841 .write()
4842 .unwrap_or_else(|poisoned| poisoned.into_inner());
4843 let mut next_catalog = catalog.clone();
4844 next_catalog
4845 .insert_idempotent(prepared.bundle().clone())
4846 .map_err(|_| {
4847 failure(
4848 NodeFailureCode::DeliveryStageConflict,
4849 "delivery bundle identity conflicts with installed content",
4850 )
4851 })?;
4852 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4853 *catalog = next_catalog;
4854 Ok(receipt)
4855 }
4856
4857 fn abort_delivery_stage(
4858 &self,
4859 stage_id: &DeliveryStageId,
4860 ) -> Result<(), NodeFailure> {
4861 let mut store = self
4862 .delivery_store
4863 .lock()
4864 .unwrap_or_else(|poisoned| poisoned.into_inner());
4865 let store = store.as_mut().ok_or_else(|| {
4866 failure(
4867 NodeFailureCode::DeliveryStageStorageFailed,
4868 "node delivery store is unavailable",
4869 )
4870 })?;
4871 store.abort(stage_id).map_err(delivery_failure)
4872 }
4873
4874 fn resolve_context(
4875 &self,
4876 resolved: &ResolvedSpawnSpec,
4877 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4878 let Some(context_id) = resolved.context_id.as_ref() else {
4879 return Ok(None);
4880 };
4881 self.context_catalog
4882 .read()
4883 .unwrap_or_else(|poisoned| poisoned.into_inner())
4884 .get(context_id)
4885 .map(|context| context.receipt().clone())
4886 .ok_or_else(|| {
4887 failure(
4888 NodeFailureCode::UnknownContextPack,
4889 "spawn context pack is unavailable on this node",
4890 )
4891 })
4892 .map(Some)
4893 }
4894
4895 fn bundle_layout(
4896 &self,
4897 provider: &AgentId,
4898 mode: SessionMode,
4899 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4900 bundle: &NodeBundle,
4901 ) -> Result<BundleProviderLayout, NodeFailure> {
4902 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4903 failure(
4904 NodeFailureCode::BundleBindingMismatch,
4905 "spawn bundle does not match the provider and mode",
4906 )
4907 })?;
4908 if layout != BundleProviderLayout::Codex {
4909 return Ok(layout);
4910 }
4911 let profile = environment_profile.and_then(|receipt| {
4912 let profiles = self
4913 .environment_profiles
4914 .read()
4915 .unwrap_or_else(|poisoned| poisoned.into_inner());
4916 let binding = profiles.get(&receipt.profile_id)?;
4917 if binding.revision != receipt.profile_revision
4918 || &binding.provider != provider
4919 || binding.native_profile_id(mode).is_none()
4920 {
4921 return None;
4922 }
4923 self.environment_materialization_profiles
4924 .read()
4925 .unwrap_or_else(|poisoned| poisoned.into_inner())
4926 .get(&receipt.profile_id)
4927 .cloned()
4928 });
4929 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4930 return Err(failure(
4931 NodeFailureCode::BundleBindingMismatch,
4932 "Codex bundle requires an exact isolated CODEX_HOME profile",
4933 ));
4934 }
4935 Ok(layout)
4936 }
4937
4938 fn select_environment_profile(
4939 &self,
4940 instance_id: AgentInstanceId,
4941 provider: &AgentId,
4942 mode: SessionMode,
4943 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4944 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4945 let Some(environment_profile) = environment_profile else {
4946 return Ok(None);
4947 };
4948 let native_profile_id = {
4949 let profiles = self
4950 .environment_profiles
4951 .read()
4952 .unwrap_or_else(|poisoned| poisoned.into_inner());
4953 let binding = profiles
4954 .get(&environment_profile.profile_id)
4955 .ok_or_else(|| {
4956 failure(
4957 NodeFailureCode::UnknownEnvironmentProfile,
4958 "session environment profile is unavailable on this node",
4959 )
4960 })?;
4961 if &binding.revision != &environment_profile.profile_revision
4962 || &binding.provider != provider
4963 {
4964 return Err(failure(
4965 NodeFailureCode::EnvironmentProfileBindingMismatch,
4966 "session environment profile revision or provider changed",
4967 ));
4968 }
4969 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4970 failure(
4971 NodeFailureCode::EnvironmentProfileBindingMismatch,
4972 "session environment profile does not support this mode",
4973 )
4974 })?
4975 };
4976 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4977 failure(
4978 NodeFailureCode::BackendOperationFailed,
4979 "native environment profile controller is unavailable",
4980 )
4981 })?;
4982 control
4983 .select_native_launch_profile(instance_id, native_profile_id)
4984 .map_err(|_| {
4985 failure(
4986 NodeFailureCode::BackendOperationFailed,
4987 "native environment profile selection failed",
4988 )
4989 })?;
4990 Ok(Some(NativeEnvironmentSelectionGuard {
4991 control: Some(control),
4992 instance_id,
4993 }))
4994 }
4995
4996 fn install_prepared_launch_overlay(
4997 &self,
4998 instance_id: AgentInstanceId,
4999 overlay: PreparedNativeLaunchOverlay,
5000 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5001 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5002 failure(
5003 NodeFailureCode::BackendOperationFailed,
5004 "native launch profile controller is unavailable",
5005 )
5006 })?;
5007 match overlay {
5008 PreparedNativeLaunchOverlay::Environment(overlay) => {
5009 control
5010 .install_native_launch_environment_overlay(instance_id, overlay)
5011 .map_err(|_| failure(
5012 NodeFailureCode::BackendOperationFailed,
5013 "session environment overlay installation failed",
5014 ))?;
5015 Ok(None)
5016 }
5017 PreparedNativeLaunchOverlay::Instance(overlay) => {
5018 control
5019 .install_native_instance_launch_overlay(instance_id, overlay)
5020 .map_err(|_| failure(
5021 NodeFailureCode::BundleBindingMismatch,
5022 "session bundle launch overlay installation failed",
5023 ))?;
5024 Ok(Some(NativeInstanceOverlayGuard {
5025 control: Some(control),
5026 instance_id,
5027 }))
5028 }
5029 }
5030 }
5031
5032 fn environment_materialization_profile(
5033 &self,
5034 environment_profile: &ResolvedEnvironmentProfileReceipt,
5035 ) -> Option<NodeSessionMaterializationProfile> {
5036 self.environment_materialization_profiles
5037 .read()
5038 .unwrap_or_else(|poisoned| poisoned.into_inner())
5039 .get(&environment_profile.profile_id)
5040 .cloned()
5041 }
5042
5043 fn materialization_profile(
5044 &self,
5045 provider: &AgentId,
5046 mode: SessionMode,
5047 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5048 bundle: Option<&ResolvedBundleReceipt>,
5049 context: Option<&ResolvedContextPackReceipt>,
5050 ) -> Result<Option<(
5051 NodeSessionMaterializationProfile,
5052 bool,
5053 Option<BundleProviderLayout>,
5054 )>, NodeFailure> {
5055 let environment = environment_profile
5056 .and_then(|receipt| self.environment_materialization_profile(receipt));
5057 let (bundle, bundle_layout) = match bundle {
5058 Some(receipt) => {
5059 let catalog = self.bundle_catalog.read()
5060 .unwrap_or_else(|poisoned| poisoned.into_inner());
5061 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5062 NodeFailureCode::UnknownBundle,
5063 "session bundle is unavailable on this node",
5064 ))?;
5065 if installed.receipt() != *receipt {
5066 return Err(failure(
5067 NodeFailureCode::BundleBindingMismatch,
5068 "session bundle revision or digest changed",
5069 ));
5070 }
5071 let layout = self.bundle_layout(
5072 provider,
5073 mode,
5074 environment_profile,
5075 installed,
5076 )?;
5077 (Some(installed.clone()), Some(layout))
5078 }
5079 None => (None, None),
5080 };
5081 let has_environment_overlay = environment.is_some() || context.is_some();
5082 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5083 (None, None, None) => None,
5084 (Some(profile), None, None) => Some(Ok(profile)),
5085 (None, Some(bundle), Some(layout)) => {
5086 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5087 }
5088 (Some(profile), Some(bundle), Some(layout)) => {
5089 Some(profile.with_bundle(bundle, layout))
5090 }
5091 _ => {
5092 return Err(failure(
5093 NodeFailureCode::BundleBindingMismatch,
5094 "session bundle provider layout is unavailable",
5095 ));
5096 }
5097 };
5098 let profile = profile
5099 .transpose()
5100 .map_err(|_| failure(
5101 NodeFailureCode::BundleMaterializationFailed,
5102 "session bundle materialization profile is invalid",
5103 ))?;
5104 let profile = match (profile, context) {
5105 (Some(profile), Some(receipt)) => {
5106 let catalog = self.context_catalog
5107 .read()
5108 .unwrap_or_else(|poisoned| poisoned.into_inner());
5109 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5110 NodeFailureCode::UnknownContextPack,
5111 "session context pack is unavailable on this node",
5112 ))?;
5113 if pack.receipt() != receipt {
5114 return Err(failure(
5115 NodeFailureCode::ContextPackMaterializationFailed,
5116 "session context pack identity changed",
5117 ));
5118 }
5119 profile.with_context(pack)
5120 }
5121 (None, Some(receipt)) => {
5122 let catalog = self.context_catalog
5123 .read()
5124 .unwrap_or_else(|poisoned| poisoned.into_inner());
5125 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5126 NodeFailureCode::UnknownContextPack,
5127 "session context pack is unavailable on this node",
5128 ))?;
5129 if pack.receipt() != receipt {
5130 return Err(failure(
5131 NodeFailureCode::ContextPackMaterializationFailed,
5132 "session context pack identity changed",
5133 ));
5134 }
5135 NodeSessionMaterializationProfile::from_context(pack)
5136 }
5137 (Some(profile), None) => Ok(profile),
5138 (None, None) => return Ok(None),
5139 }
5140 .map_err(|_| failure(
5141 NodeFailureCode::ContextPackMaterializationFailed,
5142 "session context pack materialization profile is invalid",
5143 ))?;
5144 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5145 }
5146
5147 fn prepare_session_materialization<'a>(
5148 &'a self,
5149 address: &SessionAddress,
5150 provider: &AgentId,
5151 mode: SessionMode,
5152 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5153 bundle: Option<&ResolvedBundleReceipt>,
5154 context: Option<&ResolvedContextPackReceipt>,
5155 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5156 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5157 let Some((profile, has_environment_overlay, bundle_layout)) =
5158 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5159 else {
5160 return Ok(None);
5161 };
5162 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5163 failure(
5164 NodeFailureCode::BackendOperationFailed,
5165 "session environment materializer is unavailable",
5166 )
5167 })?;
5168 let materialization_failure_code = if context.is_some() {
5169 NodeFailureCode::ContextPackMaterializationFailed
5170 } else if bundle.is_some() {
5171 NodeFailureCode::BundleMaterializationFailed
5172 } else {
5173 NodeFailureCode::BackendOperationFailed
5174 };
5175 let id = MaterializationId::new(format!(
5176 "mat-{}-{}-{}",
5177 self.incarnation_id,
5178 address.session.instance_id.0,
5179 address.session.generation.0,
5180 ))
5181 .map_err(|_| {
5182 failure(
5183 NodeFailureCode::BackendOperationFailed,
5184 "session environment materialization identity failed",
5185 )
5186 })?;
5187 let owner = MaterializationOwner::Session {
5188 incarnation_id: self.incarnation_id,
5189 instance_id: address.session.instance_id,
5190 generation: address.session.generation,
5191 };
5192 let mut ownership = materializer
5193 .begin(
5194 id.clone(),
5195 environment_profile.cloned(),
5196 bundle.cloned(),
5197 context.cloned(),
5198 owner,
5199 managed_lease_id,
5200 &profile,
5201 unix_time_ms(),
5202 )
5203 .map_err(|_| {
5204 failure(
5205 materialization_failure_code,
5206 "session materialization preparation failed",
5207 )
5208 })?;
5209 {
5210 let _transaction = self
5211 .state_transaction
5212 .lock()
5213 .unwrap_or_else(|poisoned| poisoned.into_inner());
5214 let duplicate = self
5215 .materializations
5216 .lock()
5217 .unwrap_or_else(|poisoned| poisoned.into_inner())
5218 .insert(id.clone(), ownership.clone());
5219 if duplicate.is_some() {
5220 return Err(failure(
5221 NodeFailureCode::BackendOperationFailed,
5222 "session environment materialization identity conflict",
5223 ));
5224 }
5225 if let Err(error) = self.persist_state_locked() {
5226 self.materializations
5227 .lock()
5228 .unwrap_or_else(|poisoned| poisoned.into_inner())
5229 .remove(&id);
5230 return Err(persistence_failure(error));
5231 }
5232 }
5233 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5234 Ok(environment) => environment,
5235 Err(_) => {
5236 let transaction = self
5237 .state_transaction
5238 .lock()
5239 .unwrap_or_else(|poisoned| poisoned.into_inner());
5240 self.materializations
5241 .lock()
5242 .unwrap_or_else(|poisoned| poisoned.into_inner())
5243 .insert(id.clone(), ownership.clone());
5244 if self.persist_state_locked().is_err() {
5245 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5246 }
5247 drop(transaction);
5248 if ownership.state() == MaterializationState::CleanupRequired {
5249 let _ = self.cleanup_materialization(&id);
5250 }
5251 return Err(failure(
5252 materialization_failure_code,
5253 "session materialization failed",
5254 ));
5255 }
5256 };
5257 {
5258 let _transaction = self
5259 .state_transaction
5260 .lock()
5261 .unwrap_or_else(|poisoned| poisoned.into_inner());
5262 self.materializations
5263 .lock()
5264 .unwrap_or_else(|poisoned| poisoned.into_inner())
5265 .insert(id.clone(), ownership.clone());
5266 if let Err(error) = self.persist_state_locked() {
5267 drop(_transaction);
5268 let _ = self.cleanup_materialization(&id);
5269 return Err(persistence_failure(error));
5270 }
5271 }
5272 let guard = SessionMaterializationGuard {
5273 shared: self,
5274 id: Some(id),
5275 provider_home: Some(ownership.provider_home().to_path_buf()),
5276 };
5277 let bundle_arguments = match bundle {
5278 Some(receipt) => {
5279 let catalog = self.bundle_catalog.read()
5280 .unwrap_or_else(|poisoned| poisoned.into_inner());
5281 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5282 NodeFailureCode::UnknownBundle,
5283 "session bundle is unavailable on this node",
5284 ))?;
5285 if installed.receipt() != *receipt {
5286 return Err(failure(
5287 NodeFailureCode::BundleBindingMismatch,
5288 "session bundle revision or digest changed",
5289 ));
5290 }
5291 let layout = bundle_layout.ok_or_else(|| failure(
5292 NodeFailureCode::BundleBindingMismatch,
5293 "materialized bundle provider layout is unavailable",
5294 ))?;
5295 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5296 failure(
5297 NodeFailureCode::BundleMaterializationFailed,
5298 "session bundle byte revalidation failed",
5299 )
5300 })?;
5301 Some(bundle_launch_arguments(
5302 layout,
5303 ownership.bundle_root(),
5304 ownership.provider_home(),
5305 ).map_err(|_| failure(
5306 NodeFailureCode::BundleBindingMismatch,
5307 "session bundle launch binding is invalid",
5308 ))?)
5309 }
5310 None => None,
5311 };
5312 if let Some(receipt) = context {
5313 materializer
5314 .revalidate_context(&ownership, receipt)
5315 .map_err(|_| failure(
5316 NodeFailureCode::ContextPackMaterializationFailed,
5317 "session context pack byte revalidation failed",
5318 ))?;
5319 }
5320 let transport = match mode {
5321 SessionMode::Pty => TransportKind::Pty,
5322 SessionMode::Inline => TransportKind::Pipe,
5323 SessionMode::Acp => TransportKind::Acp,
5324 };
5325 let overlay = if let Some(extra_args) = bundle_arguments {
5326 Some(PreparedNativeLaunchOverlay::Instance(
5327 NativeInstanceLaunchOverlay::new(
5328 provider.clone(),
5329 transport,
5330 if has_environment_overlay {
5331 environment
5332 } else {
5333 Vec::new()
5334 },
5335 extra_args,
5336 )
5337 .map_err(|_| {
5338 failure(
5339 NodeFailureCode::BundleBindingMismatch,
5340 "session bundle launch overlay is invalid",
5341 )
5342 })?,
5343 ))
5344 } else if has_environment_overlay {
5345 let context_only = environment_profile.is_none()
5346 && bundle.is_none()
5347 && context.is_some();
5348 let overlay = if context_only {
5349 NativeLaunchEnvironmentOverlay::new_context_root(
5350 provider.clone(),
5351 transport,
5352 environment,
5353 )
5354 .map_err(|_| {
5355 failure(
5356 NodeFailureCode::ContextPackMaterializationFailed,
5357 "context-only launch overlay is invalid",
5358 )
5359 })?
5360 } else {
5361 NativeLaunchEnvironmentOverlay::new(
5362 provider.clone(),
5363 transport,
5364 environment,
5365 )
5366 .map_err(|_| {
5367 failure(
5368 NodeFailureCode::BackendOperationFailed,
5369 "session environment overlay is invalid",
5370 )
5371 })?
5372 };
5373 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5374 } else {
5375 None
5376 };
5377 Ok(Some((overlay, guard)))
5378 }
5379
5380 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5381 let Some(mut ownership) = self
5382 .materializations
5383 .lock()
5384 .unwrap_or_else(|poisoned| poisoned.into_inner())
5385 .get(id)
5386 .cloned()
5387 else {
5388 return Ok(());
5389 };
5390 if ownership.state() != MaterializationState::CleanupRequired {
5391 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5392 failure(
5393 NodeFailureCode::BackendOperationFailed,
5394 "session environment cleanup state is invalid",
5395 )
5396 })?;
5397 }
5398 {
5399 let _transaction = self
5400 .state_transaction
5401 .lock()
5402 .unwrap_or_else(|poisoned| poisoned.into_inner());
5403 let previous = self
5404 .materializations
5405 .lock()
5406 .unwrap_or_else(|poisoned| poisoned.into_inner())
5407 .insert(id.clone(), ownership.clone());
5408 if let Err(error) = self.persist_state_locked() {
5409 let mut materializations = self
5410 .materializations
5411 .lock()
5412 .unwrap_or_else(|poisoned| poisoned.into_inner());
5413 if let Some(previous) = previous {
5414 materializations.insert(id.clone(), previous);
5415 }
5416 return Err(persistence_failure(error));
5417 }
5418 }
5419 let cleanup = self
5420 .session_environment_materializer
5421 .as_ref()
5422 .ok_or(())
5423 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5424 if cleanup.is_err() {
5425 let _ = ownership.mark_recovery_required(unix_time_ms());
5426 self.materializations
5427 .lock()
5428 .unwrap_or_else(|poisoned| poisoned.into_inner())
5429 .insert(id.clone(), ownership);
5430 if self.persist_state().is_err() {
5431 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5432 }
5433 return Err(failure(
5434 NodeFailureCode::BackendOperationFailed,
5435 "session environment cleanup requires recovery",
5436 ));
5437 }
5438 let _transaction = self
5439 .state_transaction
5440 .lock()
5441 .unwrap_or_else(|poisoned| poisoned.into_inner());
5442 self.materializations
5443 .lock()
5444 .unwrap_or_else(|poisoned| poisoned.into_inner())
5445 .remove(id);
5446 if let Err(error) = self.persist_state_locked() {
5447 self.materializations
5448 .lock()
5449 .unwrap_or_else(|poisoned| poisoned.into_inner())
5450 .insert(id.clone(), ownership);
5451 return Err(persistence_failure(error));
5452 }
5453 Ok(())
5454 }
5455
5456 fn resolve_record_materialization(
5457 &self,
5458 record_id: &SessionRecordId,
5459 provider: &AgentId,
5460 mode: SessionMode,
5461 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5462 bundle: Option<&ResolvedBundleReceipt>,
5463 context: Option<&ResolvedContextPackReceipt>,
5464 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5465 let ownership = {
5466 let materializations = self
5467 .materializations
5468 .lock()
5469 .unwrap_or_else(|poisoned| poisoned.into_inner());
5470 let mut matches = materializations.values().filter(|record| {
5471 record.owner()
5472 == &MaterializationOwner::Record {
5473 record_id: record_id.clone(),
5474 }
5475 });
5476 let ownership = matches.next().cloned();
5477 if matches.next().is_some() {
5478 return Err(failure(
5479 NodeFailureCode::BackendOperationFailed,
5480 "managed session has conflicting environment ownership",
5481 ));
5482 }
5483 ownership
5484 };
5485 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5486 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5487 failure(
5488 NodeFailureCode::ContextPackMaterializationFailed,
5489 "session context materializer is unavailable",
5490 )
5491 })?;
5492 let pack = materializer
5493 .revalidate_context(ownership, receipt)
5494 .map_err(|_| {
5495 self.mark_materialization_recovery_required(ownership.id());
5496 failure(
5497 NodeFailureCode::ContextPackMaterializationFailed,
5498 "managed session context pack revalidation failed",
5499 )
5500 })?;
5501 self.context_catalog
5502 .write()
5503 .unwrap_or_else(|poisoned| poisoned.into_inner())
5504 .insert(pack)
5505 .map_err(|_| failure(
5506 NodeFailureCode::ContextPackMaterializationFailed,
5507 "managed session context pack catalog restore failed",
5508 ))?;
5509 }
5510 let materialization = self.materialization_profile(
5511 provider,
5512 mode,
5513 environment_profile,
5514 bundle,
5515 context,
5516 )?;
5517 match (ownership, materialization) {
5518 (None, None) => Ok(None),
5519 (None, Some(_)) | (Some(_), None) => Err(failure(
5520 NodeFailureCode::BackendOperationFailed,
5521 "managed session materialization is unavailable",
5522 )),
5523 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5524 if ownership.environment_profile() != environment_profile
5525 || ownership.bundle() != bundle
5526 || ownership.context() != context
5527 || ownership.state() != MaterializationState::Ready
5528 {
5529 self.mark_materialization_recovery_required(ownership.id());
5530 return Err(failure(
5531 NodeFailureCode::BackendOperationFailed,
5532 "managed session environment ownership requires recovery",
5533 ));
5534 }
5535 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5536 failure(
5537 NodeFailureCode::BackendOperationFailed,
5538 "session environment materializer is unavailable",
5539 )
5540 })?;
5541 if materializer.revalidate(&ownership).is_err() {
5542 self.mark_materialization_recovery_required(ownership.id());
5543 return Err(failure(
5544 NodeFailureCode::BackendOperationFailed,
5545 "managed session environment revalidation failed",
5546 ));
5547 }
5548 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5549 let catalog = self.bundle_catalog.read()
5550 .unwrap_or_else(|poisoned| poisoned.into_inner());
5551 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5552 return Err(failure(
5553 NodeFailureCode::UnknownBundle,
5554 "managed session bundle is unavailable",
5555 ));
5556 };
5557 let layout = bundle_layout.ok_or_else(|| failure(
5558 NodeFailureCode::BundleBindingMismatch,
5559 "record bundle provider layout is unavailable",
5560 ))?;
5561 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5562 drop(catalog);
5563 self.mark_materialization_recovery_required(ownership.id());
5564 return Err(failure(
5565 NodeFailureCode::BundleMaterializationFailed,
5566 "managed session bundle revalidation failed",
5567 ));
5568 }
5569 Some(bundle_launch_arguments(
5570 layout,
5571 ownership.bundle_root(),
5572 ownership.provider_home(),
5573 ).map_err(|_| failure(
5574 NodeFailureCode::BundleBindingMismatch,
5575 "managed session bundle launch binding is invalid",
5576 ))?)
5577 } else {
5578 None
5579 };
5580 if !has_environment_materialization {
5581 let overlay = bundle_arguments.map(|extra_args| {
5582 NativeInstanceLaunchOverlay::new(
5583 provider.clone(),
5584 TransportKind::Pty,
5585 Vec::new(),
5586 extra_args,
5587 )
5588 .map(PreparedNativeLaunchOverlay::Instance)
5589 .map_err(|_| failure(
5590 NodeFailureCode::BundleBindingMismatch,
5591 "managed session bundle launch overlay is invalid",
5592 ))
5593 }).transpose()?;
5594 return Ok(Some((ownership.id().clone(), overlay)));
5595 }
5596 let environment = match materializer.resolve_environment(&ownership, &profile) {
5597 Ok(environment) => environment,
5598 Err(
5599 SessionEnvironmentMaterializeError::SecretUnavailable
5600 | SessionEnvironmentMaterializeError::SecretDenied
5601 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5602 ) => {
5603 return Err(failure(
5604 NodeFailureCode::BackendOperationFailed,
5605 "managed session environment resolution failed",
5606 ));
5607 }
5608 Err(
5609 SessionEnvironmentMaterializeError::InvalidRoot
5610 | SessionEnvironmentMaterializeError::OwnershipMismatch
5611 | SessionEnvironmentMaterializeError::Filesystem(_)
5612 | SessionEnvironmentMaterializeError::Record(_),
5613 ) => {
5614 self.mark_materialization_recovery_required(ownership.id());
5615 return Err(failure(
5616 NodeFailureCode::BackendOperationFailed,
5617 "managed session environment resolution failed",
5618 ));
5619 }
5620 };
5621 let transport = match mode {
5622 SessionMode::Pty => TransportKind::Pty,
5623 SessionMode::Inline => TransportKind::Pipe,
5624 SessionMode::Acp => TransportKind::Acp,
5625 };
5626 let overlay = match bundle_arguments {
5627 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5628 NativeInstanceLaunchOverlay::new(
5629 provider.clone(),
5630 transport,
5631 environment,
5632 extra_args,
5633 )
5634 .map_err(|_| failure(
5635 NodeFailureCode::BundleBindingMismatch,
5636 "managed session bundle launch overlay is invalid",
5637 ))?,
5638 ),
5639 None => PreparedNativeLaunchOverlay::Environment(
5640 NativeLaunchEnvironmentOverlay::new(
5641 provider.clone(),
5642 transport,
5643 environment,
5644 )
5645 .map_err(|_| {
5646 failure(
5647 NodeFailureCode::BackendOperationFailed,
5648 "managed session environment overlay is invalid",
5649 )
5650 })?,
5651 ),
5652 };
5653 Ok(Some((ownership.id().clone(), Some(overlay))))
5654 }
5655 }
5656 }
5657
5658 fn destroy_record_materialization(
5659 &self,
5660 record_id: &SessionRecordId,
5661 ) -> Result<bool, NodeFailure> {
5662 let ids = self
5663 .materializations
5664 .lock()
5665 .unwrap_or_else(|poisoned| poisoned.into_inner())
5666 .values()
5667 .filter_map(|record| {
5668 (record.owner()
5669 == &MaterializationOwner::Record {
5670 record_id: record_id.clone(),
5671 })
5672 .then(|| record.id().clone())
5673 })
5674 .collect::<Vec<_>>();
5675 if ids.len() > 1 {
5676 return Err(failure(
5677 NodeFailureCode::BackendOperationFailed,
5678 "managed session has conflicting environment ownership",
5679 ));
5680 }
5681 let Some(id) = ids.first().cloned() else {
5682 return Ok(false);
5683 };
5684 let (terminal_record, mut cleanup_ownership) = {
5685 let _transaction = self
5686 .state_transaction
5687 .lock()
5688 .unwrap_or_else(|poisoned| poisoned.into_inner());
5689 let original_record = self.record(record_id)?;
5690 let mut terminal_record = original_record.clone();
5691 terminal_record.provider_session = None;
5692 terminal_record.active_session = None;
5693 terminal_record.state = ManagedSessionState::Unavailable;
5694 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5695 terminal_record.updated_at_unix_ms = unix_time_ms();
5696 let original_ownership = self
5697 .materializations
5698 .lock()
5699 .unwrap_or_else(|poisoned| poisoned.into_inner())
5700 .get(&id)
5701 .cloned()
5702 .ok_or_else(|| {
5703 failure(
5704 NodeFailureCode::BackendOperationFailed,
5705 "managed session environment ownership disappeared during cleanup",
5706 )
5707 })?;
5708 let mut cleanup_ownership = original_ownership.clone();
5709 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5710 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5711 failure(
5712 NodeFailureCode::BackendOperationFailed,
5713 "managed session environment cleanup state is invalid",
5714 )
5715 })?;
5716 }
5717 self.session_records
5718 .lock()
5719 .unwrap_or_else(|poisoned| poisoned.into_inner())
5720 .records
5721 .insert(record_id.clone(), terminal_record.clone());
5722 self.materializations
5723 .lock()
5724 .unwrap_or_else(|poisoned| poisoned.into_inner())
5725 .insert(id.clone(), cleanup_ownership.clone());
5726 if let Err(error) = self.persist_state_locked() {
5727 self.session_records
5728 .lock()
5729 .unwrap_or_else(|poisoned| poisoned.into_inner())
5730 .records
5731 .insert(record_id.clone(), original_record);
5732 self.materializations
5733 .lock()
5734 .unwrap_or_else(|poisoned| poisoned.into_inner())
5735 .insert(id.clone(), original_ownership);
5736 return Err(persistence_failure(error));
5737 }
5738 (terminal_record, cleanup_ownership)
5739 };
5740 let cleanup = self
5741 .session_environment_materializer
5742 .as_ref()
5743 .ok_or(())
5744 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5745 if cleanup.is_err() {
5746 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5747 self.materializations
5748 .lock()
5749 .unwrap_or_else(|poisoned| poisoned.into_inner())
5750 .insert(id.clone(), cleanup_ownership);
5751 if self.persist_state().is_err() {
5752 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5753 }
5754 return Err(failure(
5755 NodeFailureCode::BackendOperationFailed,
5756 "managed session environment cleanup requires recovery",
5757 ));
5758 }
5759 let _transaction = self
5760 .state_transaction
5761 .lock()
5762 .unwrap_or_else(|poisoned| poisoned.into_inner());
5763 self.remove_record_memory(record_id);
5764 self.materializations
5765 .lock()
5766 .unwrap_or_else(|poisoned| poisoned.into_inner())
5767 .remove(&id);
5768 if let Err(error) = self.persist_state_locked() {
5769 self.session_records
5770 .lock()
5771 .unwrap_or_else(|poisoned| poisoned.into_inner())
5772 .records
5773 .insert(record_id.clone(), terminal_record);
5774 self.materializations
5775 .lock()
5776 .unwrap_or_else(|poisoned| poisoned.into_inner())
5777 .insert(id, cleanup_ownership);
5778 return Err(persistence_failure(error));
5779 }
5780 Ok(true)
5781 }
5782
5783 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5784 let changed = {
5785 let mut materializations = self
5786 .materializations
5787 .lock()
5788 .unwrap_or_else(|poisoned| poisoned.into_inner());
5789 let Some(ownership) = materializations.get_mut(id) else {
5790 return;
5791 };
5792 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5793 };
5794 if changed && self.persist_state().is_err() {
5795 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5796 }
5797 }
5798
5799 fn reconcile_materializations(&self) {
5800 let records = self
5801 .materializations
5802 .lock()
5803 .unwrap_or_else(|poisoned| poisoned.into_inner())
5804 .values()
5805 .cloned()
5806 .collect::<Vec<_>>();
5807 for mut ownership in records {
5808 let result = match (ownership.state(), ownership.owner()) {
5809 (MaterializationState::Preparing, _)
5810 | (MaterializationState::CleanupRequired, _) => {
5811 self.cleanup_materialization(ownership.id())
5812 }
5813 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5814 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5815 self.materializations
5816 .lock()
5817 .unwrap_or_else(|poisoned| poisoned.into_inner())
5818 .insert(ownership.id().clone(), ownership.clone());
5819 self.persist_state().map_err(persistence_failure)
5820 } else {
5821 Err(failure(
5822 NodeFailureCode::BackendOperationFailed,
5823 "session environment reconciliation failed",
5824 ))
5825 }
5826 }
5827 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5828 .session_environment_materializer
5829 .as_ref()
5830 .ok_or_else(|| {
5831 failure(
5832 NodeFailureCode::BackendOperationFailed,
5833 "session environment materializer is unavailable",
5834 )
5835 })
5836 .and_then(|materializer| {
5837 materializer.revalidate(&ownership).map_err(|_| failure(
5838 NodeFailureCode::BackendOperationFailed,
5839 "session materialization revalidation failed",
5840 ))?;
5841 let record = self.record(record_id)?;
5842 if record.environment_profile.as_ref() != ownership.environment_profile()
5843 || record.bundle.as_ref() != ownership.bundle()
5844 || record.context.as_ref() != ownership.context()
5845 {
5846 return Err(failure(
5847 NodeFailureCode::BackendOperationFailed,
5848 "session materialization ownership changed after restart",
5849 ));
5850 }
5851 if let Some(receipt) = ownership.bundle() {
5852 let catalog = self.bundle_catalog.read()
5853 .unwrap_or_else(|poisoned| poisoned.into_inner());
5854 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5855 NodeFailureCode::UnknownBundle,
5856 "session bundle is unavailable after restart",
5857 ))?;
5858 if bundle.receipt() != *receipt {
5859 return Err(failure(
5860 NodeFailureCode::BundleBindingMismatch,
5861 "session bundle changed after restart",
5862 ));
5863 }
5864 let layout = self.bundle_layout(
5865 &record.provider,
5866 record.mode,
5867 record.environment_profile.as_ref(),
5868 bundle,
5869 )?;
5870 materializer
5871 .revalidate_bundle(&ownership, bundle, layout)
5872 .map_err(|_| failure(
5873 NodeFailureCode::BundleMaterializationFailed,
5874 "session bundle revalidation failed",
5875 ))?;
5876 }
5877 if let Some(receipt) = ownership.context() {
5878 let pack = materializer
5879 .revalidate_context(&ownership, receipt)
5880 .map_err(|_| {
5881 failure(
5882 NodeFailureCode::ContextPackMaterializationFailed,
5883 "session context revalidation failed",
5884 )
5885 })?;
5886 self.context_catalog
5887 .write()
5888 .unwrap_or_else(|poisoned| poisoned.into_inner())
5889 .insert(pack)
5890 .map_err(|_| {
5891 failure(
5892 NodeFailureCode::ContextPackMaterializationFailed,
5893 "session context catalog restore failed",
5894 )
5895 })?;
5896 }
5897 Ok(())
5898 }),
5899 (MaterializationState::RecoveryRequired, _) => Ok(()),
5900 };
5901 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5902 let _ = ownership.mark_recovery_required(unix_time_ms());
5903 self.materializations
5904 .lock()
5905 .unwrap_or_else(|poisoned| poisoned.into_inner())
5906 .insert(ownership.id().clone(), ownership);
5907 if self.persist_state().is_err() {
5908 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5909 }
5910 }
5911 }
5912 let materialized_profiles = self
5913 .environment_materialization_profiles
5914 .read()
5915 .unwrap_or_else(|poisoned| poisoned.into_inner())
5916 .keys()
5917 .cloned()
5918 .collect::<Vec<_>>();
5919 let ownership = self
5920 .materializations
5921 .lock()
5922 .unwrap_or_else(|poisoned| poisoned.into_inner())
5923 .values()
5924 .cloned()
5925 .collect::<Vec<_>>();
5926 let changed = {
5927 let mut session_records = self
5928 .session_records
5929 .lock()
5930 .unwrap_or_else(|poisoned| poisoned.into_inner());
5931 let mut changed = false;
5932 for record in session_records.records.values_mut() {
5933 let environment_profile = record.environment_profile.as_ref();
5934 if environment_profile.is_none()
5935 && record.bundle.is_none()
5936 && record.context.is_none()
5937 {
5938 continue;
5939 }
5940 if record.bundle.is_none()
5941 && environment_profile.is_some_and(|profile| {
5942 !materialized_profiles.contains(&profile.profile_id)
5943 })
5944 {
5945 continue;
5946 }
5947 let has_exact_ownership = ownership.iter().any(|candidate| {
5948 candidate.state() == MaterializationState::Ready
5949 && candidate.environment_profile() == environment_profile
5950 && candidate.bundle() == record.bundle.as_ref()
5951 && candidate.context() == record.context.as_ref()
5952 && candidate.owner()
5953 == &MaterializationOwner::Record {
5954 record_id: record.record_id.clone(),
5955 }
5956 });
5957 if !has_exact_ownership {
5958 record.provider_session = None;
5959 record.active_session = None;
5960 record.state = ManagedSessionState::Unavailable;
5961 record.last_error = Some(if record.bundle.is_some() {
5962 "bundle-unavailable".to_owned()
5963 } else if record.context.is_some() {
5964 "context-unavailable".to_owned()
5965 } else {
5966 "environment-profile-unavailable".to_owned()
5967 });
5968 record.updated_at_unix_ms = unix_time_ms();
5969 changed = true;
5970 }
5971 }
5972 changed
5973 };
5974 if changed && self.persist_state().is_err() {
5975 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5976 }
5977 }
5978
5979 fn replay_spawn_spec(
5980 &self,
5981 spec: &SpawnSpec,
5982 now: Instant,
5983 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5984 let mut cache = self
5985 .spawn_idempotency
5986 .lock()
5987 .unwrap_or_else(|poisoned| poisoned.into_inner());
5988 cache.entries.retain(|_, entry| entry.expires_at > now);
5989 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5990 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5991 return Err(failure(
5992 NodeFailureCode::SpawnIdempotencyConflict,
5993 "spawn idempotency key was reused across spawn kinds",
5994 ));
5995 };
5996 if existing != spec {
5997 return Err(failure(
5998 NodeFailureCode::SpawnIdempotencyConflict,
5999 "spawn idempotency key was reused with a different specification",
6000 ));
6001 }
6002 return Ok(Some(result.clone()));
6003 }
6004 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6005 return Err(failure(
6006 NodeFailureCode::SpawnIdempotencyCapacity,
6007 "spawn idempotency cache reached its bounded capacity",
6008 ));
6009 }
6010 Ok(None)
6011 }
6012
6013 fn remember_spawn_spec(
6014 &self,
6015 spec: SpawnSpec,
6016 result: Result<ResolvedSpawnReceipt, NodeFailure>,
6017 accepted_at: Instant,
6018 ) {
6019 self
6020 .spawn_idempotency
6021 .lock()
6022 .unwrap_or_else(|poisoned| poisoned.into_inner())
6023 .entries
6024 .insert(
6025 spec.idempotency_key.clone(),
6026 SpawnIdempotencyEntry {
6027 value: SpawnIdempotencyValue::Standard { spec, result },
6028 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6029 },
6030 );
6031 }
6032
6033 fn claim_harness_mcp_spawn(
6034 &self,
6035 _reservation_id: &HarnessMcpReservationId,
6036 _activation_digest: &HarnessMcpActivationDigest,
6037 spec: &SpawnSpec,
6038 now: Instant,
6039 ) -> Result<(), NodeFailure> {
6040 let mut cache = self.spawn_idempotency.lock()
6041 .unwrap_or_else(|poisoned| poisoned.into_inner());
6042 cache.entries.retain(|_, entry| entry.expires_at > now);
6043 if cache.entries.contains_key(&spec.idempotency_key) {
6044 return Err(failure(
6045 NodeFailureCode::SpawnIdempotencyConflict,
6046 "spawn idempotency key was reused across spawn kinds",
6047 ));
6048 }
6049 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6050 return Err(failure(
6051 NodeFailureCode::SpawnIdempotencyCapacity,
6052 "spawn idempotency cache reached its bounded capacity",
6053 ));
6054 }
6055 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6056 value: SpawnIdempotencyValue::HarnessMcp,
6057 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6058 });
6059 Ok(())
6060 }
6061
6062 fn replay_managed_spawn(
6063 &self,
6064 request: &ManagedWorktreeSpawnRequest,
6065 now: Instant,
6066 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6067 let mut cache = self.spawn_idempotency.lock()
6068 .unwrap_or_else(|poisoned| poisoned.into_inner());
6069 cache.entries.retain(|_, entry| entry.expires_at > now);
6070 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6071 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6072 return Err(failure(
6073 NodeFailureCode::SpawnIdempotencyConflict,
6074 "spawn idempotency key was reused across spawn kinds",
6075 ));
6076 };
6077 if existing != request {
6078 return Err(failure(
6079 NodeFailureCode::SpawnIdempotencyConflict,
6080 "spawn idempotency key was reused with a different managed specification",
6081 ));
6082 }
6083 return Ok(Some(result.clone()));
6084 }
6085 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6086 return Err(failure(
6087 NodeFailureCode::SpawnIdempotencyCapacity,
6088 "spawn idempotency cache reached its bounded capacity",
6089 ));
6090 }
6091 Ok(None)
6092 }
6093
6094 fn remember_managed_spawn(
6095 &self,
6096 request: ManagedWorktreeSpawnRequest,
6097 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6098 accepted_at: Instant,
6099 ) {
6100 self.spawn_idempotency.lock()
6101 .unwrap_or_else(|poisoned| poisoned.into_inner())
6102 .entries
6103 .insert(
6104 request.spawn_spec.idempotency_key.clone(),
6105 SpawnIdempotencyEntry {
6106 value: SpawnIdempotencyValue::Managed { request, result },
6107 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6108 },
6109 );
6110 }
6111
6112 fn remember_managed_spawn_attempt(
6113 &self,
6114 durable_v2_key: Option<&SpawnIdempotencyKey>,
6115 request: ManagedWorktreeSpawnRequest,
6116 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6117 accepted_at: Instant,
6118 ) {
6119 if durable_v2_key.is_none() {
6120 self.remember_managed_spawn(request, result, accepted_at);
6121 }
6122 }
6123
6124 fn acquire_durable_managed_spawn_v2(
6125 &self,
6126 request: &ManagedWorktreeSpawnRequestV2,
6127 request_digest: String,
6128 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6129 if self.state_path.is_none() {
6130 return Err(failure(
6131 NodeFailureCode::BackendOperationFailed,
6132 "managed worktree V2 requires durable node state",
6133 ));
6134 }
6135 let _transaction = self.state_transaction
6136 .lock()
6137 .unwrap_or_else(|poisoned| poisoned.into_inner());
6138 let key = request.spawn_spec.idempotency_key.clone();
6139 let mut previous = None;
6140 {
6141 let mut replays = self.managed_spawn_replays
6142 .lock()
6143 .unwrap_or_else(|poisoned| poisoned.into_inner());
6144 if let Some(record) = replays.get_mut(&key) {
6145 if record.request_digest != request_digest {
6146 return Err(failure(
6147 NodeFailureCode::SpawnIdempotencyConflict,
6148 "managed worktree V2 idempotency key was reused with a different request",
6149 ));
6150 }
6151 match &record.state {
6152 ManagedWorktreeSpawnReplayStateV10::Pending {
6153 owner_incarnation_id,
6154 lease_id: None,
6155 } if owner_incarnation_id != &self.incarnation_id => {
6156 previous = Some(record.clone());
6157 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6158 owner_incarnation_id: self.incarnation_id,
6159 lease_id: None,
6160 };
6161 record.updated_at_unix_ms = unix_time_ms();
6162 }
6163 ManagedWorktreeSpawnReplayStateV10::Pending {
6164 lease_id: None,
6165 ..
6166 } => {
6167 return Err(failure(
6168 NodeFailureCode::BackendBusy,
6169 "managed worktree V2 reservation is already active",
6170 ));
6171 }
6172 ManagedWorktreeSpawnReplayStateV10::Pending {
6173 lease_id: Some(_),
6174 ..
6175 } => {
6176 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6177 }
6178 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6179 return Ok(DurableManagedSpawnReplayDecision::Committed(
6180 receipt.clone(),
6181 ));
6182 }
6183 }
6184 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6185 return Err(failure(
6186 NodeFailureCode::SpawnIdempotencyCapacity,
6187 "durable managed worktree replay ledger reached its bounded capacity",
6188 ));
6189 } else {
6190 let now = unix_time_ms();
6191 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6192 idempotency_key: key.clone(),
6193 request_digest,
6194 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6195 profile_id: request.worktree_profile_id.clone(),
6196 expected_profile_revision: request.expected_profile_revision.clone(),
6197 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6198 owner_incarnation_id: self.incarnation_id,
6199 lease_id: None,
6200 },
6201 created_at_unix_ms: now,
6202 updated_at_unix_ms: now,
6203 });
6204 }
6205 }
6206 if let Err(error) = self.persist_state_locked() {
6207 let mut replays = self.managed_spawn_replays
6208 .lock()
6209 .unwrap_or_else(|poisoned| poisoned.into_inner());
6210 match previous {
6211 Some(previous) => {
6212 replays.insert(key, previous);
6213 }
6214 None => {
6215 replays.remove(&key);
6216 }
6217 }
6218 return Err(persistence_failure(error));
6219 }
6220 Ok(DurableManagedSpawnReplayDecision::Reserved)
6221 }
6222
6223 fn link_durable_managed_spawn_v2_lease_locked(
6224 &self,
6225 key: &SpawnIdempotencyKey,
6226 lease_id: &ManagedWorktreeLeaseId,
6227 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6228 let mut replays = self.managed_spawn_replays
6229 .lock()
6230 .unwrap_or_else(|poisoned| poisoned.into_inner());
6231 let record = replays.get_mut(key).ok_or_else(|| {
6232 failure(
6233 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6234 "managed worktree V2 reservation disappeared before allocation",
6235 )
6236 })?;
6237 let previous = record.clone();
6238 match &mut record.state {
6239 ManagedWorktreeSpawnReplayStateV10::Pending {
6240 lease_id: current,
6241 ..
6242 }
6243 if current.is_none() =>
6244 {
6245 *current = Some(lease_id.clone());
6246 record.updated_at_unix_ms = unix_time_ms();
6247 Ok(previous)
6248 }
6249 _ => Err(failure(
6250 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6251 "managed worktree V2 reservation is not allocation-ready",
6252 )),
6253 }
6254 }
6255
6256 fn restore_durable_managed_spawn_v2_locked(
6257 &self,
6258 key: &SpawnIdempotencyKey,
6259 previous: ManagedWorktreeSpawnReplayRecordV10,
6260 ) {
6261 self.managed_spawn_replays
6262 .lock()
6263 .unwrap_or_else(|poisoned| poisoned.into_inner())
6264 .insert(key.clone(), previous);
6265 }
6266
6267 fn commit_durable_managed_spawn_v2(
6268 &self,
6269 request: &ManagedWorktreeSpawnRequestV2,
6270 receipt: ManagedWorktreeSpawnReceipt,
6271 ) -> Result<(), NodeFailure> {
6272 let _transaction = self.state_transaction
6273 .lock()
6274 .unwrap_or_else(|poisoned| poisoned.into_inner());
6275 let key = &request.spawn_spec.idempotency_key;
6276 let previous = {
6277 let mut replays = self.managed_spawn_replays
6278 .lock()
6279 .unwrap_or_else(|poisoned| poisoned.into_inner());
6280 let record = replays.get_mut(key).ok_or_else(|| {
6281 failure(
6282 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6283 "managed worktree V2 reservation disappeared before commit",
6284 )
6285 })?;
6286 let previous = record.clone();
6287 match &record.state {
6288 ManagedWorktreeSpawnReplayStateV10::Pending {
6289 lease_id: Some(lease_id),
6290 ..
6291 } if lease_id == &receipt.lease.lease_id => {}
6292 _ => {
6293 return Err(failure(
6294 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6295 "managed worktree V2 reservation is not linked to the spawned lease",
6296 ));
6297 }
6298 }
6299 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6300 record.updated_at_unix_ms = unix_time_ms();
6301 previous
6302 };
6303 if let Err(error) = self.persist_state_locked() {
6304 self.restore_durable_managed_spawn_v2_locked(key, previous);
6305 return Err(persistence_failure(error));
6306 }
6307 Ok(())
6308 }
6309
6310 fn remove_unallocated_durable_managed_spawn_v2(
6311 &self,
6312 request: &ManagedWorktreeSpawnRequestV2,
6313 ) -> Result<(), NodeFailure> {
6314 let _transaction = self.state_transaction
6315 .lock()
6316 .unwrap_or_else(|poisoned| poisoned.into_inner());
6317 let key = &request.spawn_spec.idempotency_key;
6318 let removed = {
6319 let mut replays = self.managed_spawn_replays
6320 .lock()
6321 .unwrap_or_else(|poisoned| poisoned.into_inner());
6322 match replays.get(key).map(|record| &record.state) {
6323 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6324 lease_id: None,
6325 ..
6326 }) => {
6327 replays.remove(key)
6328 }
6329 _ => None,
6330 }
6331 };
6332 let Some(removed) = removed else {
6333 return Ok(());
6334 };
6335 if let Err(error) = self.persist_state_locked() {
6336 self.managed_spawn_replays
6337 .lock()
6338 .unwrap_or_else(|poisoned| poisoned.into_inner())
6339 .insert(key.clone(), removed);
6340 return Err(persistence_failure(error));
6341 }
6342 Ok(())
6343 }
6344
6345 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6346 if spec.target.node_id != self.node_id {
6347 return Err(failure(
6348 NodeFailureCode::SpawnTargetMismatch,
6349 "spawn target does not match this node",
6350 ));
6351 }
6352 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6353 failure(
6354 NodeFailureCode::UnknownSpawnProfile,
6355 "spawn profile is unavailable on this node",
6356 )
6357 })?;
6358 let resolved = spec.resolve(defaults).map_err(|error| match error {
6359 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6360 NodeFailureCode::SpawnProfileRevisionMismatch,
6361 "spawn profile revision does not match the loaded profile",
6362 ),
6363 _ => failure(
6364 NodeFailureCode::InvalidRequest,
6365 "spawn specification could not be resolved",
6366 ),
6367 })?;
6368 if resolved.target.worktree_id.is_some() {
6369 self.require_worktree_service(&resolved.target.workspace_id)?;
6370 }
6371 let environment_profile = self.resolve_environment_profile(&resolved)?;
6372 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6373 self.resolve_context(&resolved)?;
6374 Ok(resolved)
6375 }
6376
6377 async fn resolve_spawn_workspace(
6378 &self,
6379 resolved: &ResolvedSpawnSpec,
6380 deadline: Instant,
6381 ) -> Result<WorkspaceId, NodeFailure> {
6382 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6383 return Ok(resolved.target.workspace_id.clone());
6384 };
6385 if self.managed_worktrees.lock()
6386 .unwrap_or_else(|poisoned| poisoned.into_inner())
6387 .lease_for_workspace(worktree_id)
6388 .is_some()
6389 {
6390 return Err(failure(
6391 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6392 "managed worktree targets may only be spawned through their lease request",
6393 ));
6394 }
6395 self.require_worktree_service(&resolved.target.workspace_id)?;
6396 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6397 let selected_root = self.workspace_root(worktree_id)?;
6398 let remaining = spawn_deadline_remaining(deadline)?;
6399 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6400 .await
6401 .map_err(|_| {
6402 failure(
6403 NodeFailureCode::SpawnDeadlineExceeded,
6404 "Git worktree selection exceeded the spawn deadline",
6405 )
6406 })?
6407 .map_err(git_worktree_failure)?;
6408 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6409 Ok(worktree_id.clone())
6410 }
6411
6412 async fn spawn_from_spec(
6413 &self,
6414 spec: SpawnSpec,
6415 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6416 self.spawn_from_spec_with_proxy(spec, None).await
6417 }
6418
6419 async fn spawn_from_spec_with_proxy(
6420 &self,
6421 spec: SpawnSpec,
6422 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6423 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6424 let accepted_at = Instant::now();
6425 if harness_mcp.is_none() {
6426 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6427 return replayed;
6428 }
6429 }
6430 let resolved = self.resolve_spawn_spec(&spec)?;
6431 let environment_profile = self.resolve_environment_profile(&resolved)?;
6432 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6433 let context = self.resolve_context(&resolved)?;
6434 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6435 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6436 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6437 let spawn_workspace_id = match spawn_workspace_id {
6438 Ok(workspace_id) => workspace_id,
6439 Err(error) => {
6440 let result = Err(error);
6441 if harness_mcp.is_none() {
6442 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6443 }
6444 return result;
6445 }
6446 };
6447 if self.managed_worktrees.lock()
6448 .unwrap_or_else(|poisoned| poisoned.into_inner())
6449 .lease_for_workspace(&spawn_workspace_id)
6450 .is_some()
6451 {
6452 let result = Err(failure(
6453 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6454 "managed worktree targets may only be spawned through their lease request",
6455 ));
6456 if harness_mcp.is_none() {
6457 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6458 }
6459 return result;
6460 }
6461 let result = self
6462 .spawn_session_with_deadline(
6463 spawn_workspace_id,
6464 resolved.provider.clone(),
6465 resolved.mode,
6466 resolved.terminal_size,
6467 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6468 environment_profile.clone(),
6469 bundle.clone(),
6470 context.clone(),
6471 None,
6472 None,
6473 SpawnRecordPolicy::Always,
6474 Some(deadline),
6475 &required_capabilities,
6476 harness_mcp,
6477 resolved.approval_level,
6478 resolved.network_allowlist.clone(),
6479 resolved.browser_profile_id.clone(),
6480 )
6481 .await
6482 .map(|(session, _runtime_policy)| {
6483 resolved.receipt_with_materialization(
6484 self.incarnation_id,
6485 session,
6486 environment_profile,
6487 bundle,
6488 context,
6489 )
6490 });
6491 if harness_mcp.is_none() {
6492 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6493 }
6494 result
6495 }
6496
6497 async fn spawn_from_spec_with_harness_mcp(
6498 &self,
6499 reservation_id: HarnessMcpReservationId,
6500 activation_digest: HarnessMcpActivationDigest,
6501 spec: SpawnSpec,
6502 deadline_unix_ms: u64,
6503 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6504 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6505 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6506 })?;
6507 if let Some(receipt) = registry
6508 .replay_spawn(&reservation_id, &activation_digest, &spec)
6509 .map_err(harness_mcp_failure)?
6510 {
6511 return Ok(receipt);
6512 }
6513 let resolved = self.resolve_spawn_spec(&spec)?;
6514 let prepared = registry
6515 .prepare_spawn(
6516 &reservation_id,
6517 &activation_digest,
6518 &spec,
6519 deadline_unix_ms,
6520 resolved.provider.clone(),
6521 )
6522 .map_err(harness_mcp_failure)?;
6523 self.claim_harness_mcp_spawn(
6524 &reservation_id,
6525 &activation_digest,
6526 &spec,
6527 Instant::now(),
6528 )?;
6529 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6530 let mut receipt = match spawn {
6531 Ok(receipt) => receipt,
6532 Err(error) => {
6533 let _ = registry.abort(&reservation_id, &activation_digest);
6534 return Err(error);
6535 }
6536 };
6537 let record_id = self
6538 .session_bindings
6539 .lock()
6540 .unwrap_or_else(|poisoned| poisoned.into_inner())
6541 .get(&receipt.session.session.instance_id)
6542 .and_then(|binding| binding.record_id.clone());
6543 let record_id = match record_id {
6544 Some(record_id) => record_id,
6545 None => {
6546 let rollback = self.remove_session(&receipt.session).await;
6547 let _ = registry.abort(&reservation_id, &activation_digest);
6548 if let Err(rollback_error) = rollback {
6549 return Err(rollback_error);
6550 }
6551 return Err(failure(
6552 NodeFailureCode::BindingMismatch,
6553 "harness MCP spawn did not retain its exact managed record",
6554 ));
6555 }
6556 };
6557 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6558 reservation_id: reservation_id.clone(),
6559 activation_digest: activation_digest.clone(),
6560 });
6561 if let Err(error) = registry.mark_spawned(
6562 &prepared,
6563 receipt.session.clone(),
6564 record_id,
6565 receipt.clone(),
6566 ) {
6567 let rollback = self.remove_session(&receipt.session).await;
6568 let _ = registry.abort(&reservation_id, &activation_digest);
6569 if let Err(rollback_error) = rollback {
6570 return Err(rollback_error);
6571 }
6572 return Err(harness_mcp_failure(error));
6573 }
6574 Ok(receipt)
6575 }
6576
6577 async fn spawn_managed_worktree(
6578 &self,
6579 request: ManagedWorktreeSpawnRequest,
6580 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6581 self.spawn_managed_worktree_inner(request, None).await
6582 }
6583
6584 async fn spawn_managed_worktree_inner(
6585 &self,
6586 request: ManagedWorktreeSpawnRequest,
6587 durable_v2_key: Option<&SpawnIdempotencyKey>,
6588 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6589 let accepted_at = Instant::now();
6590 if durable_v2_key.is_none() {
6591 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6592 return replayed;
6593 }
6594 }
6595 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6596 if request.spawn_spec.target.worktree_id.is_some() {
6597 let result = Err(failure(
6598 NodeFailureCode::InvalidRequest,
6599 "managed worktree spawn must not select a caller-provided worktree",
6600 ));
6601 self.remember_managed_spawn_attempt(
6602 durable_v2_key,
6603 request,
6604 result.clone(),
6605 accepted_at,
6606 );
6607 return result;
6608 }
6609 let environment_profile = self.resolve_environment_profile(&resolved)?;
6610 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6611 let context = self.resolve_context(&resolved)?;
6612 let source_workspace_id = resolved.target.workspace_id.clone();
6613 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6614 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6615 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6616 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6617 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6618 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6619 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6620 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6621 };
6622 let runtime_policy = timeout(
6623 spawn_deadline_remaining(deadline)?,
6624 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6625 ).await.map_err(|_| failure(
6626 NodeFailureCode::SpawnDeadlineExceeded,
6627 "provider admission exceeded the managed spawn deadline",
6628 ))??;
6629 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6630 return Err(failure(
6631 NodeFailureCode::UnsupportedSpawnCapability,
6632 "provider runtime does not admit a required spawn capability",
6633 ));
6634 }
6635 self.ensure_binding_capacity()?;
6636 let source_root = self.workspace_root(&source_workspace_id)?;
6637 profile.validate_for_workspace(&source_root)
6638 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6639 let base_timeout_ms = u64::try_from(
6640 spawn_deadline_remaining(deadline)?.as_millis(),
6641 ).unwrap_or(u64::MAX).max(1);
6642 let base_commit = resolve_base_commit_with_timeout(
6643 &source_root,
6644 profile.base(),
6645 base_timeout_ms,
6646 ).await.map_err(|error| {
6647 if Instant::now() >= deadline || error.message.contains("timed out") {
6648 failure(
6649 NodeFailureCode::SpawnDeadlineExceeded,
6650 "managed worktree base resolution exceeded the spawn deadline",
6651 )
6652 } else {
6653 managed_git_worktree_failure(error)
6654 }
6655 })?;
6656
6657 let lease = {
6658 let _transaction = self.state_transaction.lock()
6659 .unwrap_or_else(|poisoned| poisoned.into_inner());
6660 let previous = self.managed_worktrees.lock()
6661 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6662 let lease = self.managed_worktrees.lock()
6663 .unwrap_or_else(|poisoned| poisoned.into_inner())
6664 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6665 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6666 let previous_replay = match durable_v2_key
6667 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6668 .transpose()
6669 {
6670 Ok(previous) => previous,
6671 Err(error) => {
6672 *self.managed_worktrees.lock()
6673 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6674 return Err(error);
6675 }
6676 };
6677 if let Err(error) = self.persist_state_locked() {
6678 *self.managed_worktrees.lock()
6679 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6680 if let (Some(key), Some(previous_replay)) =
6681 (durable_v2_key, previous_replay)
6682 {
6683 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6684 }
6685 return Err(persistence_failure(error));
6686 }
6687 lease
6688 };
6689 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6690 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6691 self.managed_worktrees.lock()
6692 .unwrap_or_else(|poisoned| poisoned.into_inner())
6693 .remove_unmutated(&lease.lease_id);
6694 if self.persist_state().is_err() {
6695 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6696 }
6697 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6698 self.remember_managed_spawn_attempt(
6699 durable_v2_key,
6700 request,
6701 result.clone(),
6702 accepted_at,
6703 );
6704 return result;
6705 }
6706 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6707 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6708 Err(error) => {
6709 self.reconcile_failed_allocation(&lease, &source_root).await;
6710 let result = Err(error);
6711 self.remember_managed_spawn_attempt(
6712 durable_v2_key,
6713 request,
6714 result.clone(),
6715 accepted_at,
6716 );
6717 return result;
6718 }
6719 };
6720 let created = match create_git_worktree_with_timeout(
6721 &source_root,
6722 &lease.target_root,
6723 &lease.branch,
6724 Some(&base_commit),
6725 mutation_timeout_ms,
6726 ).await {
6727 Ok(created) => created,
6728 Err(error) => {
6729 self.reconcile_failed_allocation(&lease, &source_root).await;
6730 let result = Err(if Instant::now() >= deadline
6731 || error.message.contains("timed out")
6732 || error.message.contains("deadline")
6733 {
6734 failure(
6735 NodeFailureCode::SpawnDeadlineExceeded,
6736 "managed worktree creation exceeded the spawn deadline",
6737 )
6738 } else {
6739 managed_git_worktree_failure(error)
6740 });
6741 self.remember_managed_spawn_attempt(
6742 durable_v2_key,
6743 request,
6744 result.clone(),
6745 accepted_at,
6746 );
6747 return result;
6748 }
6749 };
6750 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6751 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6752 let result = Err(failure(
6753 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6754 "created worktree identity did not match its durable lease",
6755 ));
6756 self.remember_managed_spawn_attempt(
6757 durable_v2_key,
6758 request,
6759 result.clone(),
6760 accepted_at,
6761 );
6762 return result;
6763 }
6764 {
6765 let mut registry = self.managed_worktrees.lock()
6766 .unwrap_or_else(|poisoned| poisoned.into_inner());
6767 let current = registry.get_mut(&lease.lease_id)
6768 .expect("allocated managed worktree lease remains present");
6769 current.expected_head = Some(created.head.clone());
6770 current.state = ManagedWorktreeLeaseState::Ready;
6771 current.updated_at_unix_ms = unix_time_ms();
6772 }
6773 if let Err(error) = self.persist_state() {
6774 self.mark_managed_recovery_required(
6775 &lease.lease_id,
6776 ManagedWorktreeCleanupFailure::Backend,
6777 );
6778 let result = Err(persistence_failure(error));
6779 self.remember_managed_spawn_attempt(
6780 durable_v2_key,
6781 request,
6782 result.clone(),
6783 accepted_at,
6784 );
6785 return result;
6786 }
6787 let ready = self.managed_worktrees.lock()
6788 .unwrap_or_else(|poisoned| poisoned.into_inner())
6789 .get(&lease.lease_id)
6790 .expect("ready managed worktree lease remains present")
6791 .snapshot();
6792 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6793 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6794 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6795 let result = Err(error);
6796 self.remember_managed_spawn_attempt(
6797 durable_v2_key,
6798 request,
6799 result.clone(),
6800 accepted_at,
6801 );
6802 return result;
6803 }
6804 let spawn = self.spawn_session_with_deadline(
6805 lease.workspace_id.clone(),
6806 resolved.provider.clone(),
6807 resolved.mode,
6808 resolved.terminal_size,
6809 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6810 environment_profile.clone(),
6811 bundle.clone(),
6812 context.clone(),
6813 Some(lease.lease_id.clone()),
6814 Some(runtime_policy),
6815 SpawnRecordPolicy::Always,
6816 Some(deadline),
6817 &required_capabilities,
6818 None,
6819 resolved.approval_level,
6820 resolved.network_allowlist.clone(),
6821 resolved.browser_profile_id.clone(),
6822 ).await;
6823 let (session, effective_runtime_policy) = match spawn {
6824 Ok(spawned) => spawned,
6825 Err(error) => {
6826 if self
6827 .cleanup_managed_worktree(&lease.lease_id, true)
6828 .await
6829 .is_err()
6830 {
6831 self.mark_managed_recovery_required(
6832 &lease.lease_id,
6833 ManagedWorktreeCleanupFailure::Backend,
6834 );
6835 }
6836 let result = Err(error);
6837 self.remember_managed_spawn_attempt(
6838 durable_v2_key,
6839 request,
6840 result.clone(),
6841 accepted_at,
6842 );
6843 return result;
6844 }
6845 };
6846 let (record_id, raw_inventory_record_id) = {
6847 let mut bindings = self.session_bindings.lock()
6848 .unwrap_or_else(|poisoned| poisoned.into_inner());
6849 let binding = bindings.get_mut(&session.session.instance_id)
6850 .expect("successful spawn retains its session binding");
6851 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6852 managed_spawn_record_ownership(binding, effective_runtime_policy)
6853 };
6854 let bound = self.managed_worktrees.lock()
6855 .unwrap_or_else(|poisoned| poisoned.into_inner())
6856 .bind_session(
6857 &lease.lease_id,
6858 ManagedWorktreeSessionHolder {
6859 incarnation_id: self.incarnation_id,
6860 instance_id: session.session.instance_id,
6861 generation: session.session.generation,
6862 },
6863 record_id,
6864 unix_time_ms(),
6865 )
6866 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6867 let snapshot = match bound {
6868 Ok(snapshot) => snapshot,
6869 Err(error) => {
6870 if raw_inventory_record_id
6871 .as_ref()
6872 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6873 {
6874 self.mark_managed_recovery_required(
6875 &lease.lease_id,
6876 ManagedWorktreeCleanupFailure::Backend,
6877 );
6878 }
6879 let _ = self.remove_session(&session).await;
6880 self.mark_managed_recovery_required(
6881 &lease.lease_id,
6882 ManagedWorktreeCleanupFailure::Backend,
6883 );
6884 let result = Err(error);
6885 self.remember_managed_spawn_attempt(
6886 durable_v2_key,
6887 request,
6888 result.clone(),
6889 accepted_at,
6890 );
6891 return result;
6892 }
6893 };
6894 if let Err(error) = self.persist_state() {
6895 if raw_inventory_record_id
6896 .as_ref()
6897 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6898 {
6899 self.mark_managed_recovery_required(
6900 &lease.lease_id,
6901 ManagedWorktreeCleanupFailure::Backend,
6902 );
6903 }
6904 let _ = self.remove_session(&session).await;
6905 if self
6906 .cleanup_managed_worktree(&lease.lease_id, true)
6907 .await
6908 .is_err()
6909 {
6910 self.mark_managed_recovery_required(
6911 &lease.lease_id,
6912 ManagedWorktreeCleanupFailure::Backend,
6913 );
6914 }
6915 let result = Err(persistence_failure(error));
6916 self.remember_managed_spawn_attempt(
6917 durable_v2_key,
6918 request,
6919 result.clone(),
6920 accepted_at,
6921 );
6922 return result;
6923 }
6924 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6925 let receipt = managed_spawn_receipt(
6926 &resolved,
6927 self.incarnation_id,
6928 session,
6929 snapshot,
6930 environment_profile,
6931 bundle,
6932 context,
6933 );
6934 let result = Ok(receipt);
6935 self.remember_managed_spawn_attempt(
6936 durable_v2_key,
6937 request,
6938 result.clone(),
6939 accepted_at,
6940 );
6941 result
6942 }
6943
6944 async fn spawn_managed_worktree_v2(
6945 &self,
6946 request: ManagedWorktreeSpawnRequestV2,
6947 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6948 if request.spawn_spec.target.node_id != self.node_id {
6949 return Err(failure(
6950 NodeFailureCode::SpawnTargetMismatch,
6951 "spawn target does not match this node",
6952 ));
6953 }
6954 if request.spawn_spec.target.worktree_id.is_some() {
6955 return Err(failure(
6956 NodeFailureCode::InvalidRequest,
6957 "managed worktree spawn must not select a caller-provided worktree",
6958 ));
6959 }
6960 let request_digest = managed_spawn_request_digest_v2(&request)?;
6961 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6962 match replay {
6963 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6964 DurableManagedSpawnReplayDecision::PendingLinked => {
6965 return Err(failure(
6966 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6967 "managed worktree V2 reservation has an incomplete durable allocation",
6968 ));
6969 }
6970 DurableManagedSpawnReplayDecision::Reserved => {}
6971 }
6972 let validated = (|| {
6973 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6974 let profile = self.managed_profile(
6975 &resolved.target.workspace_id,
6976 &request.worktree_profile_id,
6977 )?;
6978 if profile.revision() != &request.expected_profile_revision {
6979 return Err(failure(
6980 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6981 "managed worktree profile revision does not match the loaded profile",
6982 ));
6983 }
6984 Ok(())
6985 })();
6986 match validated {
6987 Ok(()) => {}
6988 Err(error) => {
6989 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6990 return Err(error);
6991 }
6992 }
6993 let legacy = ManagedWorktreeSpawnRequest {
6994 spawn_spec: request.spawn_spec.clone(),
6995 worktree_profile_id: request.worktree_profile_id.clone(),
6996 };
6997 match self
6998 .spawn_managed_worktree_inner(
6999 legacy,
7000 Some(&request.spawn_spec.idempotency_key),
7001 )
7002 .await
7003 {
7004 Ok(receipt) => {
7005 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7006 Ok(receipt)
7007 }
7008 Err(error) => {
7009 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7010 Err(error)
7011 }
7012 }
7013 }
7014
7015 fn managed_profile(
7016 &self,
7017 workspace_id: &WorkspaceId,
7018 profile_id: &WorktreeProfileId,
7019 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7020 if self.worktree_service_modes.get(workspace_id).copied()
7021 != Some(WorktreeServiceMode::Managed)
7022 {
7023 return Err(failure(
7024 NodeFailureCode::UnsupportedSpawnCapability,
7025 "workspace does not enable managed worktrees",
7026 ));
7027 }
7028 self.managed_worktree_profiles.get(workspace_id)
7029 .and_then(|profiles| profiles.get(profile_id))
7030 .cloned()
7031 .ok_or_else(|| failure(
7032 NodeFailureCode::InvalidRequest,
7033 "managed worktree profile is unavailable for this workspace",
7034 ))
7035 }
7036
7037 async fn reconcile_failed_allocation(
7038 &self,
7039 lease: &ManagedWorktreeLeaseRecord,
7040 source_root: &str,
7041 ) {
7042 match list_git_worktrees(source_root).await {
7043 Ok(worktrees) => {
7044 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7045 None => {
7046 match std::fs::symlink_metadata(&lease.target_root) {
7047 Err(error) if error.kind() == io::ErrorKind::NotFound => {
7048 self.managed_worktrees.lock()
7049 .unwrap_or_else(|poisoned| poisoned.into_inner())
7050 .remove_unmutated(&lease.lease_id);
7051 let _ = self.persist_state();
7052 }
7053 _ => self.mark_managed_recovery_required(
7054 &lease.lease_id,
7055 ManagedWorktreeCleanupFailure::OwnershipConflict,
7056 ),
7057 }
7058 }
7059 Some(item) if exact_owned_worktree(
7060 lease,
7061 &item.path,
7062 item.branch.as_deref(),
7063 &item.head,
7064 ) => {
7065 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7066 }
7067 Some(_) => self.mark_managed_recovery_required(
7068 &lease.lease_id,
7069 ManagedWorktreeCleanupFailure::OwnershipConflict,
7070 ),
7071 }
7072 }
7073 Err(_) => self.mark_managed_recovery_required(
7074 &lease.lease_id,
7075 ManagedWorktreeCleanupFailure::Backend,
7076 ),
7077 }
7078 }
7079
7080 async fn reconcile_managed_worktrees(&self) {
7081 let leases = self.managed_worktrees.lock()
7082 .unwrap_or_else(|poisoned| poisoned.into_inner())
7083 .active_records()
7084 .cloned()
7085 .collect::<Vec<_>>();
7086 for lease in leases {
7087 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7088 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7089 _ => {
7090 self.mark_managed_recovery_required(
7091 &lease.lease_id,
7092 ManagedWorktreeCleanupFailure::OwnershipConflict,
7093 );
7094 continue;
7095 }
7096 };
7097 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7098 self.mark_managed_recovery_required(
7099 &lease.lease_id,
7100 ManagedWorktreeCleanupFailure::OwnershipConflict,
7101 );
7102 continue;
7103 }
7104 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7105 Ok(root) => root,
7106 Err(_) => {
7107 self.mark_managed_recovery_required(
7108 &lease.lease_id,
7109 ManagedWorktreeCleanupFailure::OwnershipConflict,
7110 );
7111 continue;
7112 }
7113 };
7114 let worktrees = match list_git_worktrees(&source_root).await {
7115 Ok(worktrees) => worktrees,
7116 Err(_) => {
7117 self.mark_managed_recovery_required(
7118 &lease.lease_id,
7119 ManagedWorktreeCleanupFailure::Backend,
7120 );
7121 continue;
7122 }
7123 };
7124 let listed = worktrees.iter()
7125 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7126 let Some(listed) = listed else {
7127 match std::fs::symlink_metadata(&lease.target_root) {
7128 Err(error) if error.kind() == io::ErrorKind::NotFound
7129 && lease.state == ManagedWorktreeLeaseState::Allocating
7130 && !lease.has_holders() => {
7131 let removed = self.managed_worktrees.lock()
7132 .unwrap_or_else(|poisoned| poisoned.into_inner())
7133 .tombstone(&lease.lease_id, unix_time_ms());
7134 if self.persist_state().is_ok() && removed.is_some() {
7135 self.publish(NodeEvent::ManagedWorktreeRemoved {
7136 lease_id: lease.lease_id.clone(),
7137 });
7138 }
7139 }
7140 _ => self.mark_managed_recovery_required(
7141 &lease.lease_id,
7142 ManagedWorktreeCleanupFailure::OwnershipConflict,
7143 ),
7144 }
7145 continue;
7146 };
7147 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7148 &lease,
7149 &listed.path,
7150 listed.branch.as_deref(),
7151 &listed.head,
7152 ) {
7153 self.mark_managed_recovery_required(
7154 &lease.lease_id,
7155 ManagedWorktreeCleanupFailure::OwnershipConflict,
7156 );
7157 continue;
7158 }
7159 if !self.workspaces.read()
7160 .unwrap_or_else(|poisoned| poisoned.into_inner())
7161 .contains_key(&lease.workspace_id)
7162 {
7163 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7164 self.mark_managed_recovery_required(
7165 &lease.lease_id,
7166 ManagedWorktreeCleanupFailure::Backend,
7167 );
7168 continue;
7169 }
7170 }
7171 let snapshot = {
7172 let mut registry = self.managed_worktrees.lock()
7173 .unwrap_or_else(|poisoned| poisoned.into_inner());
7174 let current = registry.get_mut(&lease.lease_id)
7175 .expect("reconciled managed lease remains present");
7176 current.expected_head = Some(listed.head.clone());
7177 current.cleanup_failure = None;
7178 current.state = if current.has_holders() {
7179 ManagedWorktreeLeaseState::InUse
7180 } else if current.retention == ManagedWorktreeRetention::Retain {
7181 ManagedWorktreeLeaseState::Retained
7182 } else {
7183 ManagedWorktreeLeaseState::Ready
7184 };
7185 current.updated_at_unix_ms = unix_time_ms();
7186 current.snapshot()
7187 };
7188 if self.persist_state().is_err() {
7189 self.mark_managed_recovery_required(
7190 &lease.lease_id,
7191 ManagedWorktreeCleanupFailure::Backend,
7192 );
7193 continue;
7194 }
7195 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7196 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7197 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7198 }
7199 }
7200 }
7201
7202 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7222 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7223 .session_records
7224 .lock()
7225 .unwrap_or_else(|poisoned| poisoned.into_inner())
7226 .records
7227 .values()
7228 .filter(|record| {
7229 record.state == ManagedSessionState::Live
7230 && record.exported_context.is_none()
7231 && record.provider_session.is_some()
7232 })
7233 .filter_map(|record| {
7234 record
7235 .active_session
7236 .clone()
7237 .map(|session| (record.record_id.clone(), session))
7238 })
7239 .collect();
7240 for (record_id, session) in candidates {
7241 let shared = Arc::clone(self);
7242 tokio::spawn(async move {
7243 shared
7244 .reconcile_context_pack_export_for_record(&record_id, session)
7245 .await;
7246 });
7247 }
7248 }
7249
7250 async fn reconcile_context_pack_export_for_record(
7269 &self,
7270 record_id: &SessionRecordId,
7271 session: SessionAddress,
7272 ) {
7273 let Ok(record) = self.record(record_id) else {
7274 return;
7275 };
7276 if !matches!(
7277 record.state,
7278 ManagedSessionState::Live | ManagedSessionState::Dormant
7279 ) || record.exported_context.is_some()
7280 || record.provider_session.is_none()
7281 {
7282 return;
7283 }
7284 let Ok(receipt) = self
7291 .export_context_pack_for_session_record(&record.record_id, &session, true)
7292 .await
7293 else {
7294 return;
7295 };
7296 self.set_exported_context(&record.record_id, receipt);
7297 }
7298
7299 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7304 let transaction = self
7305 .state_transaction
7306 .lock()
7307 .unwrap_or_else(|poisoned| poisoned.into_inner());
7308 let (previous, updated) = {
7309 let mut records = self
7310 .session_records
7311 .lock()
7312 .unwrap_or_else(|poisoned| poisoned.into_inner());
7313 let Some(record) = records.records.get_mut(record_id) else {
7314 return;
7315 };
7316 if record.exported_context.is_some() {
7317 return;
7318 }
7319 let previous = record.clone();
7320 record.exported_context = Some(receipt);
7321 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7322 (previous, record.clone())
7323 };
7324 if self.persist_state_locked().is_err() {
7325 self.session_records
7326 .lock()
7327 .unwrap_or_else(|poisoned| poisoned.into_inner())
7328 .records
7329 .insert(record_id.clone(), previous);
7330 return;
7331 }
7332 drop(transaction);
7333 self.publish_record(updated);
7334 }
7335
7336 fn managed_record_export_target_for_instance(
7346 &self,
7347 instance_id: AgentInstanceId,
7348 generation: SessionGeneration,
7349 ) -> Option<(SessionRecordId, SessionAddress)> {
7350 let bindings = self
7351 .session_bindings
7352 .lock()
7353 .unwrap_or_else(|poisoned| poisoned.into_inner());
7354 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7355 let record_id = binding.record_id.clone()?;
7356 Some((
7357 record_id,
7358 SessionAddress {
7359 workspace_id: binding.workspace_id.clone(),
7360 session: SessionKey { instance_id, generation },
7361 },
7362 ))
7363 }
7364
7365 fn mark_managed_recovery_required(
7366 &self,
7367 lease_id: &ManagedWorktreeLeaseId,
7368 failure_kind: ManagedWorktreeCleanupFailure,
7369 ) {
7370 let snapshot = {
7371 let mut registry = self.managed_worktrees.lock()
7372 .unwrap_or_else(|poisoned| poisoned.into_inner());
7373 let Some(lease) = registry.get_mut(lease_id) else { return };
7374 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7375 lease.cleanup_failure = Some(failure_kind);
7376 lease.updated_at_unix_ms = unix_time_ms();
7377 lease.snapshot()
7378 };
7379 let _ = self.persist_state();
7380 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7381 }
7382
7383 fn set_persistence_error(&self, error: Option<String>) {
7384 *self
7385 .persistence_error
7386 .write()
7387 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7388 .map(|message| session_registry::sanitized_persistence_summary(&message));
7389 }
7390
7391 fn persist_state(&self) -> io::Result<()> {
7392 let _transaction = self
7393 .state_transaction
7394 .lock()
7395 .unwrap_or_else(|poisoned| poisoned.into_inner());
7396 self.persist_state_locked()
7397 }
7398
7399 fn persist_state_locked(&self) -> io::Result<()> {
7400 let workspaces = self
7401 .workspaces
7402 .read()
7403 .unwrap_or_else(|poisoned| poisoned.into_inner())
7404 .clone();
7405 let records = self
7406 .session_records
7407 .lock()
7408 .unwrap_or_else(|poisoned| poisoned.into_inner())
7409 .records
7410 .values()
7411 .cloned()
7412 .collect::<Vec<_>>();
7413 let mut managed = self.managed_worktrees
7414 .lock()
7415 .unwrap_or_else(|poisoned| poisoned.into_inner());
7416 managed.reattach_record_holders(
7417 &managed_worktree_record_holders(&records),
7418 unix_time_ms(),
7419 );
7420 let materializations = self
7421 .materializations
7422 .lock()
7423 .unwrap_or_else(|poisoned| poisoned.into_inner())
7424 .values()
7425 .cloned()
7426 .collect::<Vec<_>>();
7427 let managed_spawn_replays = self.managed_spawn_replays
7428 .lock()
7429 .unwrap_or_else(|poisoned| poisoned.into_inner())
7430 .values()
7431 .cloned()
7432 .collect::<Vec<_>>();
7433 let result = session_registry::save_v10(
7434 self.state_path.as_deref(),
7435 &self.node_id,
7436 &workspaces,
7437 &records,
7438 &managed.records(),
7439 &managed.tombstones(),
7440 &materializations,
7441 &managed_spawn_replays,
7442 );
7443 match &result {
7444 Ok(warning) => self.set_persistence_error(warning.clone()),
7445 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7446 }
7447 result.map(|_| ())
7448 }
7449
7450 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7451 let _mutation_guard = self.mutation_gate.lock().await;
7452 self.begin_shutdown_locked().await
7453 }
7454
7455 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7456 if self.shutdown.swap(true, Ordering::AcqRel) {
7457 return Ok(());
7458 }
7459 SessionLifecycleHook::on_shutdown(self);
7460 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7461 let mut first_error = None;
7462 for session in &self.handle.snapshot().sessions {
7463 let remaining = deadline.saturating_duration_since(Instant::now());
7464 if remaining.is_zero() {
7465 first_error.get_or_insert_with(|| {
7466 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7467 });
7468 break;
7469 }
7470 if let Err(error) = self
7471 .dispatch_bounded(
7472 ControlCommand::Stop {
7473 instance_id: session.instance_id,
7474 force: true,
7475 },
7476 remaining,
7477 )
7478 .await
7479 {
7480 first_error.get_or_insert(error);
7481 }
7482 }
7483 self.shutdown_notify.notify_waiters();
7484 match first_error {
7485 Some(error) => Err(error),
7486 None => Ok(()),
7487 }
7488 }
7489
7490 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7491 let bindings = self
7492 .session_bindings
7493 .lock()
7494 .unwrap_or_else(|poisoned| poisoned.into_inner());
7495 if bindings.len() >= CONTROL_SESSIONS_MAX {
7496 Err(failure(
7497 NodeFailureCode::BackendBusy,
7498 "node session-binding capacity is full; remove an existing session first",
7499 ))
7500 } else {
7501 Ok(())
7502 }
7503 }
7504
7505 fn ensure_workspace_environment_profile_compatible(
7512 &self,
7513 workspace_id: &WorkspaceId,
7514 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7515 ) -> Result<(), NodeFailure> {
7516 let bindings = self
7517 .session_bindings
7518 .lock()
7519 .unwrap_or_else(|poisoned| poisoned.into_inner());
7520 for binding in bindings.values() {
7521 if &binding.workspace_id != workspace_id {
7522 continue;
7523 }
7524 if binding.environment_profile.as_ref() != environment_profile {
7525 return Err(failure(
7526 NodeFailureCode::WorkspaceBusy,
7527 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7528 ));
7529 }
7530 }
7531 Ok(())
7532 }
7533
7534 #[cfg(test)]
7535 fn bind_session(
7536 &self,
7537 instance_id: AgentInstanceId,
7538 workspace_id: WorkspaceId,
7539 generation: SessionGeneration,
7540 ) {
7541 self.bind_session_with_policy(
7542 &SessionAddress {
7543 workspace_id,
7544 session: SessionKey { instance_id, generation },
7545 },
7546 ProviderRuntimePolicy::raw_pty(),
7547 None,
7548 );
7549 }
7550
7551 #[cfg(test)]
7552 fn bind_session_with_policy(
7553 &self,
7554 address: &SessionAddress,
7555 runtime_policy: ProviderRuntimePolicy,
7556 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7557 ) {
7558 self.bind_session_with_materialization(
7559 address,
7560 runtime_policy,
7561 environment_profile,
7562 None,
7563 None,
7564 None,
7565 );
7566 }
7567
7568 fn bind_session_with_materialization(
7569 &self,
7570 address: &SessionAddress,
7571 runtime_policy: ProviderRuntimePolicy,
7572 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7573 bundle: Option<ResolvedBundleReceipt>,
7574 context: Option<ResolvedContextPackReceipt>,
7575 materialization_id: Option<MaterializationId>,
7576 ) {
7577 let mut bindings = self
7578 .session_bindings
7579 .lock()
7580 .unwrap_or_else(|poisoned| poisoned.into_inner());
7581 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7582 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7583 bindings.insert(address.session.instance_id, SessionBinding {
7584 workspace_id: address.workspace_id.clone(),
7585 generation: address.session.generation,
7586 runtime_policy,
7587 pending_resume: None,
7588 record_id: None,
7589 managed_worktree_lease_id: None,
7590 environment_profile,
7591 bundle,
7592 context,
7593 materialization_id,
7594 });
7595 }
7596
7597 #[cfg(test)]
7598 fn bind_managed_session(
7599 &self,
7600 address: &SessionAddress,
7601 record_id: SessionRecordId,
7602 runtime_policy: ProviderRuntimePolicy,
7603 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7604 ) {
7605 self.bind_managed_session_with_materialization(
7606 address,
7607 record_id,
7608 runtime_policy,
7609 environment_profile,
7610 None,
7611 None,
7612 None,
7613 );
7614 }
7615
7616 fn bind_managed_session_with_materialization(
7617 &self,
7618 address: &SessionAddress,
7619 record_id: SessionRecordId,
7620 runtime_policy: ProviderRuntimePolicy,
7621 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7622 bundle: Option<ResolvedBundleReceipt>,
7623 context: Option<ResolvedContextPackReceipt>,
7624 materialization_id: Option<MaterializationId>,
7625 ) {
7626 let mut bindings = self
7627 .session_bindings
7628 .lock()
7629 .unwrap_or_else(|poisoned| poisoned.into_inner());
7630 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7631 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7632 bindings.insert(
7633 address.session.instance_id,
7634 SessionBinding {
7635 workspace_id: address.workspace_id.clone(),
7636 generation: address.session.generation,
7637 runtime_policy,
7638 pending_resume: None,
7639 record_id: Some(record_id),
7640 managed_worktree_lease_id: None,
7641 environment_profile,
7642 bundle,
7643 context,
7644 materialization_id,
7645 },
7646 );
7647 }
7648
7649 #[cfg(test)]
7650 fn bind_spawn_session(
7651 &self,
7652 address: &SessionAddress,
7653 provider: AgentId,
7654 mode: SessionMode,
7655 runtime_policy: ProviderRuntimePolicy,
7656 record_policy: SpawnRecordPolicy,
7657 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7658 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7659 self.bind_spawn_session_with_materialization(
7660 address,
7661 provider,
7662 mode,
7663 runtime_policy,
7664 record_policy,
7665 environment_profile,
7666 None,
7667 None,
7668 None,
7669 )
7670 }
7671
7672 fn bind_spawn_session_with_materialization(
7673 &self,
7674 address: &SessionAddress,
7675 provider: AgentId,
7676 mode: SessionMode,
7677 runtime_policy: ProviderRuntimePolicy,
7678 record_policy: SpawnRecordPolicy,
7679 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7680 bundle: Option<ResolvedBundleReceipt>,
7681 context: Option<ResolvedContextPackReceipt>,
7682 materialization_id: Option<MaterializationId>,
7683 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7684 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7685 && !runtime_policy.provider_session_identity
7686 {
7687 self.bind_session_with_materialization(
7688 address,
7689 runtime_policy,
7690 environment_profile,
7691 bundle,
7692 context,
7693 materialization_id,
7694 );
7695 return Ok(None);
7696 }
7697 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7698 let record = self.new_record(
7699 address,
7700 provider,
7701 mode,
7702 active_record_state(runtime_policy.provider_session_identity, false),
7703 None,
7704 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7705 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7706 record_owns_lifecycle.then(|| context.clone()).flatten(),
7707 )?;
7708 let record_id = record.record_id.clone();
7709 let transaction = self
7710 .state_transaction
7711 .lock()
7712 .unwrap_or_else(|poisoned| poisoned.into_inner());
7713 self.insert_record(record.clone())?;
7714 let previous_materialization = if record_owns_lifecycle {
7715 if let Some(materialization_id) = materialization_id.as_ref() {
7716 let mut materializations = self
7717 .materializations
7718 .lock()
7719 .unwrap_or_else(|poisoned| poisoned.into_inner());
7720 let Some(ownership) = materializations.get_mut(materialization_id) else {
7721 drop(materializations);
7722 self.remove_record_memory(&record_id);
7723 return Err(failure(
7724 NodeFailureCode::BackendOperationFailed,
7725 "session environment ownership disappeared before binding",
7726 ));
7727 };
7728 let previous = ownership.clone();
7729 if ownership
7730 .transfer_to_record(record_id.clone(), unix_time_ms())
7731 .is_err()
7732 {
7733 drop(materializations);
7734 self.remove_record_memory(&record_id);
7735 return Err(failure(
7736 NodeFailureCode::BackendOperationFailed,
7737 "session environment ownership transfer failed",
7738 ));
7739 }
7740 Some(previous)
7741 } else {
7742 None
7743 }
7744 } else {
7745 None
7746 };
7747 self.bind_managed_session_with_materialization(
7748 address,
7749 record_id.clone(),
7750 runtime_policy,
7751 environment_profile,
7752 bundle,
7753 context,
7754 materialization_id.clone(),
7755 );
7756 if let Err(error) = self.persist_state_locked() {
7757 self.remove_binding(address);
7758 self.remove_record_memory(&record_id);
7759 if let (Some(materialization_id), Some(previous)) =
7760 (materialization_id.as_ref(), previous_materialization)
7761 {
7762 self.materializations
7763 .lock()
7764 .unwrap_or_else(|poisoned| poisoned.into_inner())
7765 .insert(materialization_id.clone(), previous);
7766 }
7767 return Err(persistence_failure(error));
7768 }
7769 drop(transaction);
7770 self.publish_record(record);
7771 Ok(Some(record_id))
7772 }
7773
7774 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7775 for _ in 0..8 {
7776 let bytes = random_nonce().map_err(|error| {
7777 failure(
7778 NodeFailureCode::BackendOperationFailed,
7779 &format!("session record identity generation failed: {error}"),
7780 )
7781 })?;
7782 let mut value = String::with_capacity(27);
7783 value.push_str("sr-");
7784 for byte in &bytes[..12] {
7785 use std::fmt::Write as _;
7786 let _ = write!(&mut value, "{byte:02x}");
7787 }
7788 let record_id = SessionRecordId::new(value).map_err(|error| {
7789 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7790 })?;
7791 let records = self
7792 .session_records
7793 .lock()
7794 .unwrap_or_else(|poisoned| poisoned.into_inner());
7795 if !records.records.contains_key(&record_id) {
7796 return Ok(record_id);
7797 }
7798 }
7799 Err(failure(
7800 NodeFailureCode::BackendBusy,
7801 "could not allocate a unique session record ID",
7802 ))
7803 }
7804
7805 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7806 for _ in 0..8 {
7807 let bytes = random_nonce().map_err(|error| {
7808 failure(
7809 NodeFailureCode::BackendOperationFailed,
7810 &format!("task identity generation failed: {error}"),
7811 )
7812 })?;
7813 let mut nonce = [0_u8; 12];
7814 nonce.copy_from_slice(&bytes[..12]);
7815 let task_id = TaskId::from_nonce(nonce);
7816 let records = self
7817 .session_records
7818 .lock()
7819 .unwrap_or_else(|poisoned| poisoned.into_inner());
7820 if !records.records.values().any(|record| {
7821 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7822 == Some(&task_id)
7823 }) {
7824 return Ok(task_id);
7825 }
7826 }
7827 Err(failure(
7828 NodeFailureCode::BackendBusy,
7829 "could not allocate a unique task ID",
7830 ))
7831 }
7832
7833 fn default_record_name(&self, provider: &AgentId) -> String {
7834 let records = self
7835 .session_records
7836 .lock()
7837 .unwrap_or_else(|poisoned| poisoned.into_inner());
7838 let ordinal = records
7839 .records
7840 .values()
7841 .filter(|record| &record.provider == provider)
7842 .count()
7843 .saturating_add(1);
7844 format!("{} #{ordinal}", provider.as_str())
7845 }
7846
7847 fn new_record(
7848 &self,
7849 address: &SessionAddress,
7850 provider: AgentId,
7851 mode: SessionMode,
7852 state: ManagedSessionState,
7853 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7854 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7855 bundle: Option<ResolvedBundleReceipt>,
7856 context: Option<ResolvedContextPackReceipt>,
7857 ) -> Result<ManagedSessionRecord, NodeFailure> {
7858 let canonical_root = self.workspace_root(&address.workspace_id)?;
7859 let now = unix_time_ms();
7860 Ok(ManagedSessionRecord {
7861 record_id: self.allocate_record_id()?,
7862 display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
7863 provider,
7864 mode,
7865 state,
7866 workspace_id: address.workspace_id.clone(),
7867 canonical_root: opaque_windows_path(canonical_root),
7868 provider_session,
7869 active_session: Some(address.clone()),
7870 environment_profile,
7871 bundle,
7872 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7873 context,
7874 exported_context: None,
7875 task_binding: None,
7876 created_at_unix_ms: now,
7877 updated_at_unix_ms: now,
7878 last_error: None,
7879 })
7880 }
7881
7882 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7883 let mut records = self
7884 .session_records
7885 .lock()
7886 .unwrap_or_else(|poisoned| poisoned.into_inner());
7887 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7888 return Err(failure(
7889 NodeFailureCode::BackendBusy,
7890 "managed session record capacity is full; forget an inactive record first",
7891 ));
7892 }
7893 if let Some(identity) = record.provider_session.as_ref() {
7894 if records.records.values().any(|candidate| {
7895 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7896 session_registry::same_provider_session(
7897 &candidate.provider,
7898 candidate_identity,
7899 &record.provider,
7900 identity,
7901 )
7902 })
7903 }) {
7904 return Err(failure(
7905 NodeFailureCode::SessionRecordConflict,
7906 "provider session is already represented by another managed record",
7907 ));
7908 }
7909 }
7910 match records.records.entry(record.record_id.clone()) {
7911 std::collections::btree_map::Entry::Vacant(entry) => {
7912 entry.insert(record);
7913 }
7914 std::collections::btree_map::Entry::Occupied(_) => {
7915 return Err(failure(
7916 NodeFailureCode::SessionRecordConflict,
7917 "session record ID collision",
7918 ));
7919 }
7920 }
7921 Ok(())
7922 }
7923
7924 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7925 self.session_records
7926 .lock()
7927 .unwrap_or_else(|poisoned| poisoned.into_inner())
7928 .records
7929 .get(record_id)
7930 .cloned()
7931 .ok_or_else(|| {
7932 failure(
7933 NodeFailureCode::UnknownSessionRecord,
7934 "managed session record does not exist",
7935 )
7936 })
7937 }
7938
7939 fn revalidate_session_record_preview(
7940 &self,
7941 expected: &ManagedSessionRecord,
7942 identity: &ProviderSessionIdentity,
7943 ) -> Result<(), NodeFailure> {
7944 let current = self.record(&expected.record_id)?;
7945 let current_root = self.workspace_root(¤t.workspace_id)?;
7946 if current.provider != expected.provider
7947 || current.workspace_id != expected.workspace_id
7948 || current.canonical_root != expected.canonical_root
7949 || current.provider_session.as_ref() != Some(identity)
7950 || !platform::roots_equal(
7951 ¤t_root,
7952 windows_path_text(¤t.canonical_root),
7953 )
7954 {
7955 return Err(failure(
7956 NodeFailureCode::SessionRecordConflict,
7957 "managed session changed while its preview was loading",
7958 ));
7959 }
7960 Ok(())
7961 }
7962
7963 fn revalidate_session_record_context_export(
7964 &self,
7965 expected: &ManagedSessionRecord,
7966 identity: Option<&ProviderSessionIdentity>,
7967 session: &SessionAddress,
7968 allow_clean_detachment: bool,
7969 ) -> Result<(), NodeFailure> {
7970 let current = self.record(&expected.record_id)?;
7971 let current_root = self.workspace_root(¤t.workspace_id)?;
7972 let snapshot = self.internal_session_snapshot(session)?;
7973 let binding = self
7974 .session_bindings
7975 .lock()
7976 .unwrap_or_else(|poisoned| poisoned.into_inner())
7977 .get(&session.session.instance_id)
7978 .cloned()
7979 .ok_or_else(|| {
7980 failure(
7981 NodeFailureCode::SessionRecordConflict,
7982 "managed session runtime binding disappeared during context export",
7983 )
7984 })?;
7985 if !session_record_context_export_binding_is_exact(
7986 expected,
7987 ¤t,
7988 identity,
7989 session,
7990 &binding,
7991 &snapshot.agent_id,
7992 ¤t_root,
7993 allow_clean_detachment,
7994 ) || !session_record_context_export_source_is_usable(
7995 ¤t,
7996 &snapshot.status,
7997 allow_clean_detachment,
7998 ) {
7999 return Err(failure(
8000 NodeFailureCode::SessionRecordConflict,
8001 "managed session binding changed during context export",
8002 ));
8003 }
8004 Ok(())
8005 }
8006
8007 fn revalidate_loaded_history(
8008 &self,
8009 session: &SessionAddress,
8010 candidate_id: &str,
8011 loaded: &HistorySessionRecord,
8012 ) -> Result<(), NodeFailure> {
8013 let snapshot = self.internal_session_snapshot(session)?;
8014 if snapshot.history.pending.is_some()
8015 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8016 || snapshot.history.loaded.as_ref() != Some(loaded)
8017 {
8018 return Err(failure(
8019 NodeFailureCode::SessionRecordConflict,
8020 "managed session history changed during context export",
8021 ));
8022 }
8023 Ok(())
8024 }
8025
8026 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8027 self.session_records
8028 .lock()
8029 .unwrap_or_else(|poisoned| poisoned.into_inner())
8030 .records
8031 .remove(record_id)
8032 }
8033
8034 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8035 if self.destroy_record_materialization(record_id)? {
8036 self.publish(NodeEvent::SessionRecordRemoved {
8037 record_id: record_id.clone(),
8038 });
8039 return Ok(());
8040 }
8041 let transaction = self
8042 .state_transaction
8043 .lock()
8044 .unwrap_or_else(|poisoned| poisoned.into_inner());
8045 let Some(removed) = self.remove_record_memory(record_id) else {
8046 return Ok(());
8047 };
8048 if let Err(error) = self.persist_state_locked() {
8049 let _ = self.insert_record(removed);
8050 return Err(persistence_failure(error));
8051 }
8052 drop(transaction);
8053 self.publish(NodeEvent::SessionRecordRemoved {
8054 record_id: record_id.clone(),
8055 });
8056 Ok(())
8057 }
8058
8059 fn publish_record(&self, record: ManagedSessionRecord) {
8060 self.publish(NodeEvent::SessionRecordUpserted { record });
8061 }
8062
8063 fn mark_record_error(
8064 &self,
8065 record_id: &SessionRecordId,
8066 message: &str,
8067 ) -> Result<(), NodeFailure> {
8068 let transaction = self
8069 .state_transaction
8070 .lock()
8071 .unwrap_or_else(|poisoned| poisoned.into_inner());
8072 let previous = {
8073 let mut records = self
8074 .session_records
8075 .lock()
8076 .unwrap_or_else(|poisoned| poisoned.into_inner());
8077 let Some(record) = records.records.get_mut(record_id) else {
8078 return Ok(());
8079 };
8080 let previous = record.clone();
8081 record.state = ManagedSessionState::Unavailable;
8082 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8083 record.updated_at_unix_ms = unix_time_ms();
8084 previous
8085 };
8086 if let Err(error) = self.persist_state_locked() {
8087 self.session_records
8088 .lock()
8089 .unwrap_or_else(|poisoned| poisoned.into_inner())
8090 .records
8091 .insert(record_id.clone(), previous);
8092 return Err(persistence_failure(error));
8093 }
8094 let record = self.record(record_id)?;
8095 drop(transaction);
8096 self.publish_record(record);
8097 Ok(())
8098 }
8099
8100 fn rename_session_record(
8101 &self,
8102 record_id: &SessionRecordId,
8103 display_name: String,
8104 ) -> Result<ManagedSessionRecord, NodeFailure> {
8105 validate_display_name(&display_name)
8106 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8107 let _transaction = self
8108 .state_transaction
8109 .lock()
8110 .unwrap_or_else(|poisoned| poisoned.into_inner());
8111 let previous = {
8112 let mut records = self
8113 .session_records
8114 .lock()
8115 .unwrap_or_else(|poisoned| poisoned.into_inner());
8116 let record = records.records.get_mut(record_id).ok_or_else(|| {
8117 failure(
8118 NodeFailureCode::UnknownSessionRecord,
8119 "managed session record does not exist",
8120 )
8121 })?;
8122 let previous = record.clone();
8123 record.display_name = display_name;
8124 record.updated_at_unix_ms = unix_time_ms();
8125 previous
8126 };
8127 if let Err(error) = self.persist_state_locked() {
8128 self.session_records
8129 .lock()
8130 .unwrap_or_else(|poisoned| poisoned.into_inner())
8131 .records
8132 .insert(record_id.clone(), previous);
8133 return Err(persistence_failure(error));
8134 }
8135 let updated = self.record(record_id)?;
8136 self.publish_record(updated.clone());
8137 Ok(updated)
8138 }
8139
8140 fn set_session_task(
8141 &self,
8142 record_id: &SessionRecordId,
8143 expected_revision: u64,
8144 target: SessionTaskTargetV1,
8145 ) -> Result<ManagedSessionRecord, NodeFailure> {
8146 let transaction = self
8147 .state_transaction
8148 .lock()
8149 .unwrap_or_else(|poisoned| poisoned.into_inner());
8150 let current = self.record(record_id)?;
8151 let current_task_id = current
8152 .task_binding
8153 .as_ref()
8154 .and_then(|binding| binding.task_id.as_ref());
8155 let current_revision = current
8156 .task_binding
8157 .as_ref()
8158 .map_or(0, |binding| binding.revision);
8159 if expected_revision != current_revision {
8160 return Err(failure(
8161 NodeFailureCode::SessionRecordConflict,
8162 "managed session task binding revision changed",
8163 ));
8164 }
8165 let exact = match &target {
8166 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8167 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8168 SessionTaskTargetV1::New => false,
8169 };
8170 if exact {
8171 return Ok(current);
8172 }
8173 let revision = current_revision.checked_add(1).ok_or_else(|| {
8174 failure(
8175 NodeFailureCode::SessionRecordConflict,
8176 "managed session task binding revision is exhausted",
8177 )
8178 })?;
8179 let task_id = match target {
8180 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8181 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8182 SessionTaskTargetV1::Clear => None,
8183 };
8184 let changed_at_unix_ms = unix_time_ms()
8185 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8186 failure(
8187 NodeFailureCode::SessionRecordConflict,
8188 "managed session record timestamp is exhausted",
8189 )
8190 })?);
8191 let updated = {
8192 let mut records = self
8193 .session_records
8194 .lock()
8195 .unwrap_or_else(|poisoned| poisoned.into_inner());
8196 let record = records.records.get_mut(record_id).ok_or_else(|| {
8197 failure(
8198 NodeFailureCode::UnknownSessionRecord,
8199 "managed session record does not exist",
8200 )
8201 })?;
8202 record.task_binding = Some(SessionTaskBindingV1 {
8203 revision,
8204 task_id,
8205 changed_at_unix_ms,
8206 });
8207 record.updated_at_unix_ms = changed_at_unix_ms;
8208 record.clone()
8209 };
8210 if let Err(error) = self.persist_state_locked() {
8211 self.session_records
8212 .lock()
8213 .unwrap_or_else(|poisoned| poisoned.into_inner())
8214 .records
8215 .insert(record_id.clone(), current);
8216 return Err(persistence_failure(error));
8217 }
8218 drop(transaction);
8219 self.publish_record(updated.clone());
8220 Ok(updated)
8221 }
8222
8223 fn index_provider_session(
8224 &self,
8225 workspace_id: WorkspaceId,
8226 provider: AgentId,
8227 identity: ProviderSessionIdentity,
8228 display_name: String,
8229 ) -> Result<ManagedSessionRecord, NodeFailure> {
8230 self.index_provider_session_with_policy(
8231 workspace_id,
8232 provider,
8233 identity,
8234 display_name,
8235 false,
8236 )
8237 }
8238
8239 fn index_provider_session_with_policy(
8240 &self,
8241 workspace_id: WorkspaceId,
8242 provider: AgentId,
8243 identity: ProviderSessionIdentity,
8244 display_name: String,
8245 allow_validated_transcript_path: bool,
8246 ) -> Result<ManagedSessionRecord, NodeFailure> {
8247 validate_display_name(&display_name)
8248 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8249 identity
8250 .validate()
8251 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8252 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8253 return Err(failure(
8254 NodeFailureCode::InvalidRequest,
8255 "provider session reference index v1 does not accept transcript paths",
8256 ));
8257 }
8258 let canonical_root = self.workspace_root(&workspace_id)?;
8259 if !self.enabled_providers.contains(&provider) {
8260 return Err(failure(
8261 NodeFailureCode::InvalidRequest,
8262 "provider is not enabled on this node",
8263 ));
8264 }
8265
8266 let transaction = self
8267 .state_transaction
8268 .lock()
8269 .unwrap_or_else(|poisoned| poisoned.into_inner());
8270 {
8271 let records = self
8272 .session_records
8273 .lock()
8274 .unwrap_or_else(|poisoned| poisoned.into_inner());
8275 if let Some(existing) = records.records.values().find(|candidate| {
8276 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8277 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8278 })
8279 }) {
8280 if existing.provider == provider && existing.workspace_id == workspace_id {
8281 return Ok(existing.clone());
8282 }
8283 return Err(failure(
8284 NodeFailureCode::SessionRecordConflict,
8285 "provider session reference conflicts with another provider or workspace",
8286 ));
8287 }
8288 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8289 return Err(failure(
8290 NodeFailureCode::BackendBusy,
8291 "managed session record capacity is full; forget an inactive record first",
8292 ));
8293 }
8294 }
8295
8296 let now = unix_time_ms();
8297 let record = ManagedSessionRecord {
8298 record_id: self.allocate_record_id()?,
8299 display_name,
8300 provider,
8301 mode: SessionMode::Pty,
8302 state: ManagedSessionState::Dormant,
8303 workspace_id,
8304 canonical_root: opaque_windows_path(canonical_root),
8305 provider_session: Some(identity),
8306 active_session: None,
8307 environment_profile: None,
8308 bundle: None,
8309 context_id: None,
8310 context: None,
8311 exported_context: None,
8312 task_binding: None,
8313 created_at_unix_ms: now,
8314 updated_at_unix_ms: now,
8315 last_error: None,
8316 };
8317 self.insert_record(record.clone())?;
8318 if let Err(error) = self.persist_state_locked() {
8319 self.remove_record_memory(&record.record_id);
8320 return Err(persistence_failure(error));
8321 }
8322 drop(transaction);
8323 self.publish_record(record.clone());
8324 Ok(record)
8325 }
8326
8327 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8328 self.session_bindings
8329 .lock()
8330 .unwrap_or_else(|poisoned| poisoned.into_inner())
8331 .iter()
8332 .find_map(|(instance_id, binding)| {
8333 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8334 workspace_id: binding.workspace_id.clone(),
8335 session: SessionKey {
8336 instance_id: *instance_id,
8337 generation: binding.generation,
8338 },
8339 })
8340 })
8341 }
8342
8343 async fn forget_session_record(
8344 &self,
8345 record_id: &SessionRecordId,
8346 ) -> Result<(), NodeFailure> {
8347 let record = self.record(record_id)?;
8348 if record.active_session.is_some()
8349 || matches!(
8350 record.state,
8351 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8352 )
8353 {
8354 return Err(failure(
8355 NodeFailureCode::SessionRecordBusy,
8356 "stop the managed session before forgetting it",
8357 ));
8358 }
8359 if let Some(address) = self.bound_address_for_record(record_id) {
8360 self.remove_session(&address).await?;
8361 }
8362 self.remove_managed_session_record(record_id, &record.workspace_id).await
8363 }
8364
8365 async fn remove_managed_session_record(
8380 &self,
8381 record_id: &SessionRecordId,
8382 workspace_id: &WorkspaceId,
8383 ) -> Result<(), NodeFailure> {
8384 let managed_lease_id = self.managed_worktrees.lock()
8385 .unwrap_or_else(|poisoned| poisoned.into_inner())
8386 .lease_for_workspace(workspace_id);
8387 if !self.destroy_record_materialization(record_id)? {
8388 let transaction = self
8389 .state_transaction
8390 .lock()
8391 .unwrap_or_else(|poisoned| poisoned.into_inner());
8392 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8393 failure(
8394 NodeFailureCode::UnknownSessionRecord,
8395 "managed session record does not exist",
8396 )
8397 })?;
8398 if let Err(error) = self.persist_state_locked() {
8399 let _ = self.insert_record(removed);
8400 return Err(persistence_failure(error));
8401 }
8402 drop(transaction);
8403 }
8404 self.publish(NodeEvent::SessionRecordRemoved {
8405 record_id: record_id.clone(),
8406 });
8407 if let Some(lease_id) = managed_lease_id {
8408 if let Some(snapshot) = self.managed_worktrees.lock()
8409 .unwrap_or_else(|poisoned| poisoned.into_inner())
8410 .get(&lease_id)
8411 .map(ManagedWorktreeLeaseRecord::snapshot)
8412 {
8413 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8414 }
8415 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8416 }
8417 Ok(())
8418 }
8419
8420 async fn retire_unavailable_session_records(&self) {
8433 let policy = self.session_record_retention;
8434 let age_enabled = policy.age_ms > 0;
8435 let keep_enabled = policy.keep_per_workspace > 0;
8436 if !age_enabled && !keep_enabled {
8437 return;
8438 }
8439 let now = unix_time_ms();
8440 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8441 self.session_records
8442 .lock()
8443 .unwrap_or_else(|poisoned| poisoned.into_inner())
8444 .records
8445 .values()
8446 .filter(|record| record.state == ManagedSessionState::Unavailable)
8447 .map(|record| {
8448 (
8449 record.record_id.clone(),
8450 record.workspace_id.clone(),
8451 record.provider.clone(),
8452 record.updated_at_unix_ms,
8453 )
8454 })
8455 .collect()
8456 };
8457 if candidates.is_empty() {
8458 return;
8459 }
8460 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8461 BTreeMap::new();
8462 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8463 by_workspace
8464 .entry(workspace_id)
8465 .or_default()
8466 .push((record_id, provider, updated_at_unix_ms));
8467 }
8468 let reason = if age_enabled && keep_enabled {
8469 "age+keep-n"
8470 } else if age_enabled {
8471 "age"
8472 } else {
8473 "keep-n"
8474 };
8475 let mut retired: u64 = 0;
8476 for (workspace_id, mut records) in by_workspace {
8477 records.sort_by(|left, right| right.2.cmp(&left.2));
8480 for (index, (record_id, provider, updated_at_unix_ms)) in
8481 records.into_iter().enumerate()
8482 {
8483 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8484 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8485 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8486 let should_retire = if age_enabled && keep_enabled {
8487 age_hit && keep_hit
8488 } else {
8489 age_hit || keep_hit
8490 };
8491 if !should_retire {
8492 continue;
8493 }
8494 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8495 Ok(()) => {
8496 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8497 retired += 1;
8498 tracing::info!(
8499 node_id = %self.node_id,
8500 record_id = %record_id,
8501 workspace_id = %workspace_id,
8502 provider = %provider,
8503 age_ms = record_age_ms,
8504 reason,
8505 "managed session record retired"
8506 );
8507 }
8508 Err(error) => tracing::warn!(
8509 node_id = %self.node_id,
8510 record_id = %record_id,
8511 workspace_id = %workspace_id,
8512 code = ?error.code,
8513 cause = %error.message,
8514 "session record retention sweep failed to retire a record"
8515 ),
8516 }
8517 }
8518 }
8519 if retired > 0 {
8520 let remaining_unavailable = self
8521 .session_records
8522 .lock()
8523 .unwrap_or_else(|poisoned| poisoned.into_inner())
8524 .records
8525 .values()
8526 .filter(|record| record.state == ManagedSessionState::Unavailable)
8527 .count();
8528 tracing::info!(
8529 node_id = %self.node_id,
8530 retired,
8531 remaining_unavailable,
8532 "session record retention sweep completed"
8533 );
8534 }
8535 }
8536
8537 async fn resume_session_record(
8538 &self,
8539 record_id: &SessionRecordId,
8540 terminal_size: gate4agent_types::TerminalSize,
8541 initial_prompt: Option<String>,
8542 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8543 let record = self.record(record_id)?;
8544 if record.active_session.is_some()
8545 || matches!(
8546 record.state,
8547 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8548 )
8549 {
8550 return Err(failure(
8551 NodeFailureCode::SessionRecordBusy,
8552 "managed session already has a live runtime binding",
8553 ));
8554 }
8555 let identity = record.provider_session.clone().ok_or_else(|| {
8556 failure(
8557 NodeFailureCode::SessionRecordNotResumable,
8558 "managed session has no verified provider session identity",
8559 )
8560 })?;
8561 let working_directory = self.workspace_root(&record.workspace_id)?;
8562 if !platform::roots_equal(
8563 &working_directory,
8564 windows_path_text(&record.canonical_root),
8565 ) {
8566 return Err(failure(
8567 NodeFailureCode::SessionRecordConflict,
8568 "managed session workspace root changed; refusing to resume in another directory",
8569 ));
8570 }
8571 {
8572 let records = self
8573 .session_records
8574 .lock()
8575 .unwrap_or_else(|poisoned| poisoned.into_inner());
8576 if records.records.values().any(|candidate| {
8577 candidate.record_id != *record_id
8578 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8579 session_registry::same_provider_session(
8580 &candidate.provider,
8581 candidate_identity,
8582 &record.provider,
8583 &identity,
8584 )
8585 })
8586 && (candidate.active_session.is_some()
8587 || matches!(
8588 candidate.state,
8589 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8590 ))
8591 }) {
8592 return Err(failure(
8593 NodeFailureCode::SessionRecordConflict,
8594 "provider session is already active through another managed record",
8595 ));
8596 }
8597 }
8598 let runtime_requirement = if initial_prompt.is_some() {
8599 ProviderRuntimeRequirement::ResumeWithPrompt
8600 } else {
8601 ProviderRuntimeRequirement::Resume
8602 };
8603 let runtime_policy = self.admit_provider_runtime(
8604 &record.provider,
8605 runtime_requirement,
8606 )
8607 .await?;
8608 let working_directory = match self.m4a_accounts.as_ref() {
8610 Some(_) => {
8611 let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8612 if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8613 }
8614 None => working_directory,
8615 };
8616 let request = ResumeLaunchRequest {
8617 working_directory,
8618 terminal_size,
8619 initial_prompt,
8620 };
8621 request
8622 .validate()
8623 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8624 self.ensure_binding_capacity()?;
8625 let resolved_materialization = match self.resolve_record_materialization(
8626 record_id,
8627 &record.provider,
8628 record.mode,
8629 record.environment_profile.as_ref(),
8630 record.bundle.as_ref(),
8631 record.context.as_ref(),
8632 ) {
8633 Ok(materialization) => materialization,
8634 Err(error) => {
8635 let label = match error.code {
8636 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8637 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8638 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8639 _ => "environment-profile-unavailable",
8640 };
8641 self.mark_record_error(record_id, label)?;
8642 return Err(error);
8643 }
8644 };
8645 let (materialization_id, environment_overlay) = match resolved_materialization {
8646 Some((id, overlay)) => (Some(id), overlay),
8647 None => (None, None),
8648 };
8649 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8650 let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8652 (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8653 if let Some(old) = self.bound_address_for_record(record_id) {
8654 if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8655 self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8656 }
8657 }
8658 let session_key = self.m4a_session_key(instance_id);
8659 self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key)?
8660 }
8661 _ => environment_overlay,
8662 };
8663 let mut environment_selection = match SpawnPreparer::select_environment(self,
8664 instance_id,
8665 &record.provider,
8666 record.mode,
8667 record.environment_profile.as_ref(),
8668 ) {
8669 Ok(selection) => selection,
8670 Err(error) => {
8671 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8672 return Err(error);
8673 }
8674 };
8675 let mut instance_overlay = environment_overlay
8676 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8677 .transpose()?
8678 .flatten();
8679 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8680 self.remove_session(&stale_address).await?;
8681 }
8682 let address = SessionAddress {
8683 workspace_id: record.workspace_id.clone(),
8684 session: SessionKey {
8685 instance_id,
8686 generation: SessionGeneration(1),
8687 },
8688 };
8689 let transaction = self
8690 .state_transaction
8691 .lock()
8692 .unwrap_or_else(|poisoned| poisoned.into_inner());
8693 let current = self.record(record_id)?;
8694 if current.active_session.is_some()
8695 || matches!(
8696 current.state,
8697 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8698 )
8699 {
8700 return Err(failure(
8701 NodeFailureCode::SessionRecordBusy,
8702 "managed session became active while resume was being prepared",
8703 ));
8704 }
8705 self.bind_managed_session_with_materialization(
8706 &address,
8707 record_id.clone(),
8708 runtime_policy,
8709 record.environment_profile.clone(),
8710 record.bundle.clone(),
8711 record.context.clone(),
8712 materialization_id,
8713 );
8714 if let Some(selection) = environment_selection.take() {
8715 selection.retain();
8716 }
8717 if let Some(overlay) = instance_overlay.take() {
8718 overlay.retain();
8719 }
8720 drop(transaction);
8721
8722 let transport = match record.mode {
8723 SessionMode::Pty => TransportKind::Pty,
8724 SessionMode::Inline => TransportKind::Pipe,
8725 SessionMode::Acp => TransportKind::Acp,
8726 };
8727 let agent_id = record.provider.clone();
8728 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8729 if let Err(error) = self
8730 .dispatch_bounded(
8731 ControlCommand::Register {
8732 instance_id,
8733 agent_id,
8734 transport,
8735 },
8736 dispatch_timeout,
8737 )
8738 .await
8739 {
8740 if let Some(binding) = self.remove_binding(&address) {
8741 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8742 }
8743 return Err(error);
8744 }
8745 let command = self.prepare_command(ControlCommand::Resume {
8746 instance_id,
8747 target: ResumeTarget::ProviderSession { identity },
8748 runtime_policy,
8749 request,
8750 });
8751 let started_after = self.current_sequence();
8752 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8753 self.rollback_spawn(&address, dispatch_timeout).await?;
8754 return Err(error);
8755 }
8756 if let Err(error) = self.dispatch_envelope(command) {
8757 self.clear_armed_resume(&address);
8758 self.rollback_spawn(&address, dispatch_timeout).await?;
8759 return Err(error);
8760 }
8761 match self
8762 .wait_for_record_resume(&address, started_after)
8763 .await
8764 {
8765 Ok(settled_address) => {
8766 let managed_lease_id = {
8767 self.managed_worktrees.lock()
8768 .unwrap_or_else(|poisoned| poisoned.into_inner())
8769 .lease_for_workspace(&record.workspace_id)
8770 };
8771 if let Some(lease_id) = managed_lease_id {
8772 {
8773 let mut bindings = self.session_bindings.lock()
8774 .unwrap_or_else(|poisoned| poisoned.into_inner());
8775 let binding = bindings.get_mut(&settled_address.session.instance_id)
8776 .ok_or_else(|| failure(
8777 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8778 "resumed managed session lost its exact binding",
8779 ))?;
8780 if binding.generation != settled_address.session.generation {
8781 return Err(failure(
8782 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8783 "resumed managed session generation diverged",
8784 ));
8785 }
8786 binding.managed_worktree_lease_id = Some(lease_id.clone());
8787 }
8788 let snapshot = self.managed_worktrees.lock()
8789 .unwrap_or_else(|poisoned| poisoned.into_inner())
8790 .bind_session(
8791 &lease_id,
8792 ManagedWorktreeSessionHolder {
8793 incarnation_id: self.incarnation_id,
8794 instance_id: settled_address.session.instance_id,
8795 generation: settled_address.session.generation,
8796 },
8797 Some(record_id.clone()),
8798 unix_time_ms(),
8799 ).map_err(|message| failure(
8800 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8801 &message,
8802 ))?;
8803 if let Err(error) = self.persist_state() {
8804 let _ = self.remove_session(&settled_address).await;
8805 return Err(persistence_failure(error));
8806 }
8807 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8808 }
8809 Ok((self.record(record_id)?, settled_address))
8810 }
8811 Err(error) => {
8812 if matches!(
8813 error.code,
8814 NodeFailureCode::SessionRecordNotResumable
8815 | NodeFailureCode::BackendOperationFailed
8816 ) {
8817 if let Some(bound) = self.bound_address_for_record(record_id) {
8818 let _ = self.remove_session(&bound).await;
8819 }
8820 }
8821 Err(error)
8822 }
8823 }
8824 }
8825
8826 async fn wait_for_record_resume(
8827 &self,
8828 initial_address: &SessionAddress,
8829 after_sequence: u64,
8830 ) -> Result<SessionAddress, NodeFailure> {
8831 let deadline = Instant::now()
8832 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8833 let mut scan_after = after_sequence;
8834 loop {
8835 let events = self
8836 .history
8837 .lock()
8838 .unwrap_or_else(|poisoned| poisoned.into_inner())
8839 .events
8840 .iter()
8841 .filter(|event| event.sequence > scan_after)
8842 .cloned()
8843 .collect::<Vec<_>>();
8844 for envelope in events {
8845 scan_after = scan_after.max(envelope.sequence);
8846 let NodeEvent::Control { address, event } = envelope.event else {
8847 continue;
8848 };
8849 if address.session.instance_id != initial_address.session.instance_id {
8850 continue;
8851 }
8852 match event.event {
8853 ControlEventKind::Resumed { .. } => return Ok(address),
8854 ControlEventKind::ResumeDenied { reason } => {
8855 return Err(failure(
8856 NodeFailureCode::SessionRecordNotResumable,
8857 &reason,
8858 ));
8859 }
8860 ControlEventKind::ResumeFailed { message }
8861 | ControlEventKind::CommandRejected { message } => {
8862 return Err(failure(
8863 NodeFailureCode::BackendOperationFailed,
8864 &message,
8865 ));
8866 }
8867 _ => {}
8868 }
8869 }
8870 if Instant::now() >= deadline {
8871 return Err(failure(
8872 NodeFailureCode::BackendBusy,
8873 "managed resume did not settle before the bounded deadline",
8874 ));
8875 }
8876 sleep(Duration::from_millis(5)).await;
8877 }
8878 }
8879
8880 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8881 self.workspaces
8882 .read()
8883 .unwrap_or_else(|poisoned| poisoned.into_inner())
8884 .get(workspace_id)
8885 .cloned()
8886 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8887 }
8888
8889 fn workspace_roots(&self) -> Vec<PathBuf> {
8890 self.workspaces
8891 .read()
8892 .unwrap_or_else(|poisoned| poisoned.into_inner())
8893 .values()
8894 .map(PathBuf::from)
8895 .collect()
8896 }
8897
8898 async fn inspect_workspace(
8899 &self,
8900 workspace_id: WorkspaceId,
8901 ) -> Result<WorkspaceInspection, NodeFailure> {
8902 let _permit = self
8903 .inspection_slots
8904 .clone()
8905 .try_acquire_owned()
8906 .map_err(|_| {
8907 tracing::warn!(
8908 workspace_id = %workspace_id,
8909 "workspace inspection rejected: inspection capacity is busy",
8910 );
8911 failure(
8912 NodeFailureCode::BackendBusy,
8913 "workspace inspection capacity is busy",
8914 )
8915 })?;
8916 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8917 tracing::warn!(
8918 workspace_id = %workspace_id,
8919 code = ?error.code,
8920 "workspace inspection rejected: workspace is not registered",
8921 );
8922 error
8923 })?;
8924 let tree_root = canonical_root.clone();
8925 let started_at = Instant::now();
8926 let time_budget_ms = workspace_inspection_time_budget_ms();
8927 let entry_cap = workspace_inspection_entry_cap();
8928 let deadline = started_at + Duration::from_millis(time_budget_ms);
8929 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8930 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8931 })
8932 .await
8933 .map_err(|error| {
8934 tracing::warn!(
8935 workspace_id = %workspace_id,
8936 error = %error,
8937 "workspace inspection rejected: walk task failed",
8938 );
8939 failure(
8940 NodeFailureCode::BackendOperationFailed,
8941 &format!("workspace inspection task failed: {error}"),
8942 )
8943 })?;
8944 let (mut git, git_time_budget_exceeded) =
8945 inspect_git_workspace(&canonical_root, deadline).await;
8946 if !git.worktrees.is_empty() {
8947 let registered = self
8948 .workspaces
8949 .read()
8950 .unwrap_or_else(|poisoned| poisoned.into_inner())
8951 .iter()
8952 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8953 .collect::<Vec<_>>();
8954 for worktree in &mut git.worktrees {
8955 worktree.workspace_id = registered
8956 .iter()
8957 .find(|(_, root)| {
8958 worktree_paths_equal(root, windows_path_text(&worktree.path))
8959 })
8960 .map(|(workspace_id, _)| workspace_id.clone());
8961 }
8962 }
8963 if git.is_repository {
8964 git.managed_worktree = self
8965 .managed_worktrees
8966 .lock()
8967 .unwrap_or_else(|poisoned| poisoned.into_inner())
8968 .git_scope(&workspace_id, git.branch.as_deref());
8969 }
8970 let truncation = workspace_inspection_truncation(
8971 &walk_budget,
8972 git_time_budget_exceeded,
8973 started_at.elapsed(),
8974 );
8975 if let Some(truncation) = &truncation {
8976 tracing::info!(
8977 workspace_id = %workspace_id,
8978 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8979 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8980 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8981 entries_visited = truncation.entries_visited,
8982 elapsed_ms = truncation.elapsed_ms,
8983 "workspace inspection truncated by its inner budget",
8984 );
8985 }
8986 Ok(WorkspaceInspection {
8987 workspace_id,
8988 entries,
8989 tree_truncated,
8990 git,
8991 truncation,
8992 })
8993 }
8994
8995 async fn browse_host_directories(
8996 &self,
8997 directory: Option<OpaqueHostPath>,
8998 after: Option<OpaqueHostPath>,
8999 ) -> Result<HostDirectoryListing, NodeFailure> {
9000 let permit = self
9001 .inspection_slots
9002 .clone()
9003 .try_acquire_owned()
9004 .map_err(|_| {
9005 failure(
9006 NodeFailureCode::BackendBusy,
9007 "host directory browse capacity is busy",
9008 )
9009 })?;
9010 let task = tokio::task::spawn_blocking(move || {
9011 let _permit = permit;
9012 browse_host_directories(directory, after)
9013 });
9014 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9015 .await
9016 .map_err(|_| {
9017 failure(
9018 NodeFailureCode::HostDirectoryReadTimedOut,
9019 "host directory browse exceeded its bounded deadline",
9020 )
9021 })?
9022 .map_err(|_| {
9023 failure(
9024 NodeFailureCode::HostDirectoryReadFailed,
9025 "host directory browse task failed",
9026 )
9027 })?
9028 .map_err(host_directory_failure)
9029 }
9030
9031 async fn read_workspace_file(
9032 &self,
9033 workspace_id: WorkspaceId,
9034 path: RepositoryPath,
9035 ) -> Result<WorkspaceFileRead, NodeFailure> {
9036 let canonical_root = self.workspace_root(&workspace_id)?;
9037 let permit = self
9038 .inspection_slots
9039 .clone()
9040 .try_acquire_owned()
9041 .map_err(|_| {
9042 failure(
9043 NodeFailureCode::BackendBusy,
9044 "workspace file read capacity is busy",
9045 )
9046 })?;
9047 let path_to_read = path.clone();
9048 let task = tokio::task::spawn_blocking(move || {
9049 let _permit = permit;
9050 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9051 });
9052 let content = timeout(
9053 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9054 task,
9055 )
9056 .await
9057 .map_err(|_| {
9058 failure(
9059 NodeFailureCode::RepositoryFileReadTimedOut,
9060 "repository file read exceeded its bounded deadline",
9061 )
9062 })?
9063 .map_err(|_| {
9064 failure(
9065 NodeFailureCode::RepositoryFileReadFailed,
9066 "repository file read task failed",
9067 )
9068 })?
9069 .map_err(workspace_file_failure)?;
9070 let content = match content {
9071 WorkspaceFileBytes::Utf8(text) => {
9072 let revision = Some(workspace_file_revision(text.as_bytes()));
9073 return Ok(WorkspaceFileRead {
9074 workspace_id,
9075 path,
9076 content: WorkspaceFileContent::Utf8 {
9077 byte_len: u32::try_from(text.len())
9078 .expect("bounded workspace text length must fit u32"),
9079 text,
9080 },
9081 revision,
9082 });
9083 }
9084 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9085 WorkspaceFileContent::NonUtf8 {
9086 byte_len: u32::try_from(byte_length)
9087 .expect("bounded workspace file length must fit u32"),
9088 }
9089 }
9090 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9091 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9092 },
9093 };
9094 Ok(WorkspaceFileRead {
9095 workspace_id,
9096 path,
9097 content,
9098 revision: None,
9099 })
9100 }
9101
9102 async fn write_workspace_file(
9103 &self,
9104 workspace_id: WorkspaceId,
9105 path: RepositoryPath,
9106 expected_revision: WorkspaceFileRevision,
9107 text: String,
9108 ) -> Result<WorkspaceFileRead, NodeFailure> {
9109 let canonical_root = self.workspace_root(&workspace_id)?;
9110 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9111 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9112 })?;
9113 let path_to_write = path.clone();
9114 let expected = expected_revision.as_str().to_owned();
9115 let text_to_write = text.clone();
9116 let task = tokio::task::spawn_blocking(move || {
9117 let _permit = permit;
9118 write_workspace_file_to_disk(
9119 Path::new(&canonical_root),
9120 &path_to_write,
9121 &expected,
9122 &text_to_write,
9123 )
9124 });
9125 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9126 .await
9127 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9128 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9129 .map_err(workspace_file_write_failure)?;
9130 Ok(WorkspaceFileRead {
9131 workspace_id,
9132 path,
9133 content: WorkspaceFileContent::Utf8 {
9134 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9135 text,
9136 },
9137 revision: Some(WorkspaceFileRevision::new(revision)
9138 .expect("workspace writer returns a valid SHA-256 revision")),
9139 })
9140 }
9141
9142 async fn create_workspace_file(
9143 &self,
9144 workspace_id: WorkspaceId,
9145 path: RepositoryPath,
9146 ) -> Result<WorkspaceFileRead, NodeFailure> {
9147 let canonical_root = self.workspace_root(&workspace_id)?;
9148 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9149 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9150 })?;
9151 let path_to_create = path.clone();
9152 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9153 let task_commit_state = Arc::clone(&commit_state);
9154 let task = tokio::task::spawn_blocking(move || {
9155 let _permit = permit;
9156 create_workspace_file_on_disk(
9157 Path::new(&canonical_root),
9158 &path_to_create,
9159 &task_commit_state,
9160 )
9161 });
9162 let revision = settle_workspace_entry_create(
9163 task,
9164 commit_state,
9165 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9166 "repository file create exceeded its bounded deadline",
9167 "repository file create task failed",
9168 )
9169 .await?;
9170 Ok(WorkspaceFileRead {
9171 workspace_id,
9172 path,
9173 content: WorkspaceFileContent::Utf8 {
9174 text: String::new(),
9175 byte_len: 0,
9176 },
9177 revision: Some(WorkspaceFileRevision::new(revision)
9178 .expect("workspace creator returns a valid SHA-256 revision")),
9179 })
9180 }
9181
9182 async fn create_workspace_directory(
9183 &self,
9184 workspace_id: WorkspaceId,
9185 path: RepositoryPath,
9186 ) -> Result<WorkspaceEntry, NodeFailure> {
9187 let canonical_root = self.workspace_root(&workspace_id)?;
9188 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9189 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9190 })?;
9191 let path_to_create = path.clone();
9192 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9193 let task_commit_state = Arc::clone(&commit_state);
9194 let task = tokio::task::spawn_blocking(move || {
9195 let _permit = permit;
9196 create_workspace_directory_on_disk(
9197 Path::new(&canonical_root),
9198 &path_to_create,
9199 &task_commit_state,
9200 )
9201 });
9202 settle_workspace_entry_create(
9203 task,
9204 commit_state,
9205 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9206 "repository directory create exceeded its bounded deadline",
9207 "repository directory create task failed",
9208 )
9209 .await?;
9210 Ok(WorkspaceEntry {
9211 relative_path: path,
9212 kind: WorkspaceEntryKind::Directory,
9213 })
9214 }
9215
9216 async fn read_git_history(
9217 &self,
9218 workspace_id: WorkspaceId,
9219 path: Option<RepositoryPath>,
9220 before: Option<GitObjectId>,
9221 limit: u16,
9222 ) -> Result<GitHistoryPage, NodeFailure> {
9223 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9224 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9225 })?;
9226 let root = self.workspace_root(&workspace_id)?;
9227 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9228 }
9229
9230 async fn read_git_diff(
9231 &self,
9232 workspace_id: WorkspaceId,
9233 request: GitDiffRequest,
9234 ) -> Result<GitDiff, NodeFailure> {
9235 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9236 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9237 })?;
9238 let root = self.workspace_root(&workspace_id)?;
9239 read_git_diff_bounded(&root, request).await
9240 }
9241
9242 fn native_session_route_root(
9243 &self,
9244 route: &NativeSessionCatalogRoute,
9245 ) -> Result<Option<String>, NodeFailure> {
9246 route
9247 .validate()
9248 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9249 match route.scope {
9250 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9251 .workspace_id
9252 .as_ref()
9253 .map(|workspace_id| self.workspace_root(workspace_id))
9254 .transpose(),
9255 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9256 }
9257 }
9258
9259 fn project_native_session_entries(
9260 &self,
9261 route: &NativeSessionCatalogRoute,
9262 entries: Vec<ScopedNativeSessionCatalogEntry>,
9263 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9264 let current_root = route
9265 .workspace_id
9266 .as_ref()
9267 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9268 let records = self
9269 .session_records
9270 .lock()
9271 .unwrap_or_else(|poisoned| poisoned.into_inner());
9272 let mut seen_records = std::collections::HashSet::new();
9273 entries
9274 .into_iter()
9275 .map(|entry| {
9276 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9277 let matches = records
9278 .records
9279 .values()
9280 .filter(|record| {
9281 record.provider == route.provider
9282 && record.workspace_id == *workspace_id
9283 && current_root.as_deref().is_some_and(|root| {
9284 platform::roots_equal(
9285 root,
9286 windows_path_text(&record.canonical_root),
9287 )
9288 })
9289 && record.provider_session.as_ref().is_some_and(|identity| {
9290 session_registry::same_provider_session(
9291 &record.provider,
9292 identity,
9293 &route.provider,
9294 &entry.provider_session,
9295 )
9296 })
9297 })
9298 .map(|record| record.record_id.clone())
9299 .collect::<Vec<_>>();
9300 (matches.len() == 1
9301 && seen_records.insert(matches[0].clone()))
9302 .then(|| matches[0].clone())
9303 });
9304 let metadata = entry.metadata;
9305 crate::protocol::NativeSessionCatalogEntry {
9306 selection_id: metadata.selection_id,
9307 title: metadata.title,
9308 modified_at_unix_ms: metadata.modified_at_unix_ms,
9309 model: metadata.model,
9310 message_count: metadata.message_count,
9311 completed_turn_count: metadata.completed_turn_count,
9312 external_group: entry.external_group,
9313 record_id,
9314 }
9315 })
9316 .collect()
9317 }
9318
9319 async fn catalog_native_sessions(
9320 &self,
9321 route: NativeSessionCatalogRoute,
9322 limit: u16,
9323 ) -> Result<(
9324 Vec<crate::protocol::NativeSessionCatalogEntry>,
9325 crate::protocol::NativeSessionCatalogSummary,
9326 ), NodeFailure> {
9327 let canonical_root = self.native_session_route_root(&route)?;
9328 let registered_workspace_roots = self.workspace_roots();
9329 let provider = route.provider.clone();
9330 let scope = route.scope;
9331 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9332 failure(
9333 NodeFailureCode::InvalidRequest,
9334 "native session catalog is not configured",
9335 )
9336 })?;
9337 let _permit = Arc::clone(&self.inspection_slots)
9338 .try_acquire_owned()
9339 .map_err(|_| {
9340 failure(
9341 NodeFailureCode::BackendBusy,
9342 "native session catalog capacity is busy",
9343 )
9344 })?;
9345 let page = run_native_session_catalog_operation(
9346 catalog,
9347 "native session catalog",
9348 move |catalog| {
9349 catalog.catalog_initial_for_scope(
9350 &provider,
9351 scope,
9352 canonical_root.as_deref().map(Path::new),
9353 ®istered_workspace_roots,
9354 limit,
9355 )
9356 },
9357 )
9358 .await?;
9359 let summary = crate::protocol::NativeSessionCatalogSummary {
9360 catalog_revision: page.revision,
9361 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9362 recent_total_count: page.recent_total_count,
9363 older_total_count: page.older_total_count,
9364 recent_next_after_selection_id: page.next_after_selection_id,
9365 recent_has_more: page.remaining_count > 0,
9366 };
9367 Ok((self.project_native_session_entries(&route, page.entries), summary))
9368 }
9369
9370 async fn page_native_sessions(
9371 &self,
9372 route: NativeSessionCatalogRoute,
9373 window: crate::protocol::NativeSessionCatalogWindow,
9374 catalog_revision: u64,
9375 recent_cutoff_unix_ms: u64,
9376 after_selection_id: Option<String>,
9377 limit: u16,
9378 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9379 let canonical_root = self.native_session_route_root(&route)?;
9380 let registered_workspace_roots = self.workspace_roots();
9381 let provider = route.provider.clone();
9382 let scope = route.scope;
9383 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9384 failure(
9385 NodeFailureCode::InvalidRequest,
9386 "native session catalog is not configured",
9387 )
9388 })?;
9389 let _permit = Arc::clone(&self.inspection_slots)
9390 .try_acquire_owned()
9391 .map_err(|_| {
9392 failure(
9393 NodeFailureCode::BackendBusy,
9394 "native session catalog capacity is busy",
9395 )
9396 })?;
9397 let page = run_native_session_catalog_operation(
9398 catalog,
9399 "native session catalog page",
9400 move |catalog| {
9401 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9402 &provider,
9403 scope,
9404 canonical_root.as_deref().map(Path::new),
9405 ®istered_workspace_roots,
9406 window,
9407 catalog_revision,
9408 recent_cutoff_unix_ms,
9409 after_selection_id.as_deref(),
9410 limit,
9411 ))
9412 },
9413 )
9414 .await?;
9415 let page = page.map_err(|error| match error {
9416 NativeSessionCatalogError::StaleCatalog => failure(
9417 NodeFailureCode::StaleNativeSessionCatalog,
9418 "native session catalog revision or cursor is stale",
9419 ),
9420 _ => failure(
9421 NodeFailureCode::BackendOperationFailed,
9422 "native session catalog page failed",
9423 ),
9424 })?;
9425 let entries = self.project_native_session_entries(&route, page.entries);
9426 Ok(crate::protocol::NativeSessionCatalogPage {
9427 window: page.window,
9428 revision: page.revision,
9429 entries,
9430 next_after_selection_id: page.next_after_selection_id,
9431 remaining_count: page.remaining_count,
9432 has_more: page.remaining_count > 0,
9433 })
9434 }
9435
9436 async fn preview_native_session(
9437 &self,
9438 selection: NativeSessionSelection,
9439 message_limit: u16,
9440 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9441 selection
9442 .validate()
9443 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9444 let canonical_root = self.native_session_route_root(&selection.route)?;
9445 let registered_workspace_roots = self.workspace_roots();
9446 let provider = selection.route.provider.clone();
9447 let scope = selection.route.scope;
9448 let catalog_revision = selection.catalog_revision;
9449 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9450 let selection_id = selection.selection_id.clone();
9451 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9452 failure(
9453 NodeFailureCode::InvalidRequest,
9454 "native session preview is not configured",
9455 )
9456 })?;
9457 let _permit = Arc::clone(&self.inspection_slots)
9458 .try_acquire_owned()
9459 .map_err(|_| {
9460 failure(
9461 NodeFailureCode::BackendBusy,
9462 "native session preview capacity is busy",
9463 )
9464 })?;
9465 let preview = run_native_session_catalog_operation(
9466 catalog,
9467 "native session preview",
9468 move |catalog| {
9469 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9470 &provider,
9471 scope,
9472 canonical_root.as_deref().map(Path::new),
9473 ®istered_workspace_roots,
9474 catalog_revision,
9475 recent_cutoff_unix_ms,
9476 &selection_id,
9477 message_limit,
9478 ))
9479 },
9480 )
9481 .await?;
9482 preview
9483 .map(Into::into)
9484 .map_err(native_session_preview_failure)
9485 }
9486
9487 async fn index_native_session(
9488 &self,
9489 selection: NativeSessionSelection,
9490 display_name: String,
9491 ) -> Result<ManagedSessionRecord, NodeFailure> {
9492 selection
9493 .validate()
9494 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9495 if selection.route.scope
9496 != gate4agent_types::NativeSessionCatalogScope::Workspace
9497 {
9498 return Err(failure(
9499 NodeFailureCode::WorkspaceRegistrationRequired,
9500 "register the external project as a workspace before indexing or resuming it",
9501 ));
9502 }
9503 let workspace_id = selection
9504 .route
9505 .workspace_id
9506 .clone()
9507 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9508 let canonical_root = self
9509 .native_session_route_root(&selection.route)?
9510 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9511 let registered_workspace_roots = self.workspace_roots();
9512 let provider = selection.route.provider.clone();
9513 let catalog_revision = selection.catalog_revision;
9514 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9515 let selection_id = selection.selection_id.clone();
9516 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9517 failure(
9518 NodeFailureCode::InvalidRequest,
9519 "native session index is not configured",
9520 )
9521 })?;
9522 let _permit = Arc::clone(&self.inspection_slots)
9523 .try_acquire_owned()
9524 .map_err(|_| {
9525 failure(
9526 NodeFailureCode::BackendBusy,
9527 "native session index capacity is busy",
9528 )
9529 })?;
9530 let resolved = run_native_session_catalog_operation(
9531 catalog,
9532 "native session index resolution",
9533 move |catalog| {
9534 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9535 &provider,
9536 gate4agent_types::NativeSessionCatalogScope::Workspace,
9537 Some(Path::new(&canonical_root)),
9538 ®istered_workspace_roots,
9539 catalog_revision,
9540 recent_cutoff_unix_ms,
9541 &selection_id,
9542 ))
9543 },
9544 )
9545 .await?
9546 .map_err(native_session_preview_failure)?;
9547 self.index_provider_session_with_policy(
9548 workspace_id,
9549 selection.route.provider,
9550 resolved.identity,
9551 display_name,
9552 true,
9553 )
9554 }
9555
9556 async fn preview_native_session_by_session_id(
9557 &self,
9558 workspace_id: WorkspaceId,
9559 provider: AgentId,
9560 session_id: String,
9561 message_limit: u16,
9562 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9563 let canonical_root = self.workspace_root(&workspace_id)?;
9564 let registered_workspace_roots = self.workspace_roots();
9565 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9566 failure(
9567 NodeFailureCode::InvalidRequest,
9568 "native session preview is not configured",
9569 )
9570 })?;
9571 let _permit = Arc::clone(&self.inspection_slots)
9572 .try_acquire_owned()
9573 .map_err(|_| {
9574 failure(
9575 NodeFailureCode::BackendBusy,
9576 "native session preview capacity is busy",
9577 )
9578 })?;
9579 run_native_session_catalog_operation(
9580 catalog,
9581 "native session preview",
9582 move |catalog| {
9583 catalog.preview_session_id_for_workspace(
9584 &provider,
9585 Path::new(&canonical_root),
9586 ®istered_workspace_roots,
9587 &session_id,
9588 message_limit,
9589 )
9590 },
9591 )
9592 .await
9593 }
9594
9595 async fn register_workspace(
9596 &self,
9597 workspace_id: WorkspaceId,
9598 root: String,
9599 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9600 validate_workspace_request_root(&root)?;
9601 let canonicalize_workspace_id = workspace_id.clone();
9602 let workspace = tokio::task::spawn_blocking(move || {
9603 WorkspaceConfig::new(canonicalize_workspace_id, root)
9604 })
9605 .await
9606 .map_err(|error| {
9607 failure(
9608 NodeFailureCode::BackendOperationFailed,
9609 &format!("workspace canonicalization task failed: {error}"),
9610 )
9611 })?
9612 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9613 let transaction = self
9614 .state_transaction
9615 .lock()
9616 .unwrap_or_else(|poisoned| poisoned.into_inner());
9617 let mut workspaces = self
9618 .workspaces
9619 .write()
9620 .unwrap_or_else(|poisoned| poisoned.into_inner());
9621 if workspaces.contains_key(&workspace_id) {
9622 return Err(failure(
9623 NodeFailureCode::DuplicateWorkspaceId,
9624 "workspace ID is already registered",
9625 ));
9626 }
9627 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9628 platform::roots_equal(existing_root, workspace.canonical_root())
9629 }) {
9630 return Err(failure(
9631 NodeFailureCode::DuplicateWorkspaceRoot,
9632 &format!("workspace root is already registered as '{existing_id}'"),
9633 ));
9634 }
9635 {
9636 let records = self
9637 .session_records
9638 .lock()
9639 .unwrap_or_else(|poisoned| poisoned.into_inner());
9640 if let Some(record) = records.records.values().find(|record| {
9641 record.workspace_id == workspace_id
9642 && !record
9643 .canonical_root
9644 .as_utf8()
9645 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9646 }) {
9647 return Err(failure(
9648 NodeFailureCode::SessionRecordConflict,
9649 &format!(
9650 "workspace ID is retained by session record '{}' at another root",
9651 record.record_id,
9652 ),
9653 ));
9654 }
9655 }
9656 let snapshot = WorkspaceSnapshot {
9657 workspace_id: workspace_id.clone(),
9658 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9659 sessions: Vec::new(),
9660 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9661 managed_worktree_profiles: Some(WorktreeProfileInventory {
9662 profiles: Vec::new(),
9663 }),
9664 };
9665 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9666 drop(workspaces);
9667 let previous_records = {
9668 let mut records = self
9669 .session_records
9670 .lock()
9671 .unwrap_or_else(|poisoned| poisoned.into_inner());
9672 let previous = records.records.clone();
9673 for record in records.records.values_mut().filter(|record| {
9674 record.workspace_id == workspace_id
9675 && record
9676 .canonical_root
9677 .as_utf8()
9678 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9679 }) {
9680 if record.active_session.is_none() {
9681 record.state = detached_record_state(record);
9682 }
9683 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9684 record.last_error = None;
9685 }
9686 record.updated_at_unix_ms = unix_time_ms();
9687 }
9688 previous
9689 };
9690 if let Err(error) = self.persist_state_locked() {
9691 self.workspaces
9692 .write()
9693 .unwrap_or_else(|poisoned| poisoned.into_inner())
9694 .remove(&workspace_id);
9695 self.session_records
9696 .lock()
9697 .unwrap_or_else(|poisoned| poisoned.into_inner())
9698 .records = previous_records;
9699 return Err(persistence_failure(error));
9700 }
9701 let restored = self
9702 .session_records
9703 .lock()
9704 .unwrap_or_else(|poisoned| poisoned.into_inner())
9705 .records
9706 .values()
9707 .filter(|record| record.workspace_id == workspace_id)
9708 .cloned()
9709 .collect::<Vec<_>>();
9710 drop(transaction);
9711 self.publish(NodeEvent::WorkspaceAdded {
9712 workspace: snapshot.clone(),
9713 });
9714 for record in restored {
9715 self.publish_record(record);
9716 }
9717 Ok(snapshot)
9718 }
9719
9720 async fn create_standalone_workspace(
9721 &self,
9722 workspace_id: WorkspaceId,
9723 root: String,
9724 initial_branch: Option<String>,
9725 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9726 validate_workspace_request_root(&root)?;
9727 validate_git_revision("initial branch", initial_branch.as_deref())?;
9728 let preflight_workspace_id = workspace_id.clone();
9729 let preflight_root = root.clone();
9730 let canonical_candidate = tokio::task::spawn_blocking(move || {
9731 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9732 })
9733 .await
9734 .map_err(|_| failure(
9735 NodeFailureCode::BackendOperationFailed,
9736 "standalone workspace root preflight task failed",
9737 ))?
9738 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9739 {
9740 let workspaces = self
9741 .workspaces
9742 .read()
9743 .unwrap_or_else(|poisoned| poisoned.into_inner());
9744 if workspaces.contains_key(&workspace_id) {
9745 return Err(failure(
9746 NodeFailureCode::DuplicateWorkspaceId,
9747 "workspace ID is already registered",
9748 ));
9749 }
9750 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9751 platform::roots_equal(existing_root, &canonical_candidate)
9752 }) {
9753 return Err(failure(
9754 NodeFailureCode::DuplicateWorkspaceRoot,
9755 &format!("workspace root is already registered as '{existing_id}'"),
9756 ));
9757 }
9758 }
9759 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9760 .await
9761 .map_err(standalone_workspace_failure)?;
9762 prepared
9763 .verify_for_registration()
9764 .await
9765 .map_err(standalone_workspace_failure)?;
9766 match self
9767 .register_workspace(workspace_id, prepared.root().to_owned())
9768 .await
9769 {
9770 Ok(workspace) => Ok(workspace),
9771 Err(registration_error) => {
9772 if prepared.compensate_registration_failure().await {
9773 Err(registration_error)
9774 } else {
9775 Err(failure(
9776 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9777 "standalone workspace initialization succeeded but registration failed; recovery is required",
9778 ))
9779 }
9780 }
9781 }
9782 }
9783
9784 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9785 let bindings = self
9786 .session_bindings
9787 .lock()
9788 .unwrap_or_else(|poisoned| poisoned.into_inner());
9789 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9790 return Err(failure(
9791 NodeFailureCode::WorkspaceBusy,
9792 "workspace retains a bound session; remove every session before unregistering it",
9793 ));
9794 }
9795 drop(bindings);
9796 let transaction = self
9797 .state_transaction
9798 .lock()
9799 .unwrap_or_else(|poisoned| poisoned.into_inner());
9800 let mut workspaces = self
9801 .workspaces
9802 .write()
9803 .unwrap_or_else(|poisoned| poisoned.into_inner());
9804 if !workspaces.contains_key(workspace_id) {
9805 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9806 }
9807 if workspaces.len() == 1 {
9808 return Err(failure(
9809 NodeFailureCode::LastWorkspace,
9810 "node must retain at least one workspace",
9811 ));
9812 }
9813 let removed_root = workspaces
9814 .remove(workspace_id)
9815 .expect("workspace presence was validated");
9816 drop(workspaces);
9817 let previous_records = {
9818 let mut records = self
9819 .session_records
9820 .lock()
9821 .unwrap_or_else(|poisoned| poisoned.into_inner());
9822 let previous = records.records.clone();
9823 for record in records
9824 .records
9825 .values_mut()
9826 .filter(|record| &record.workspace_id == workspace_id)
9827 {
9828 record.active_session = None;
9829 record.state = ManagedSessionState::Unavailable;
9830 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9831 record.updated_at_unix_ms = unix_time_ms();
9832 }
9833 previous
9834 };
9835 if let Err(error) = self.persist_state_locked() {
9836 self.workspaces
9837 .write()
9838 .unwrap_or_else(|poisoned| poisoned.into_inner())
9839 .insert(workspace_id.clone(), removed_root);
9840 self.session_records
9841 .lock()
9842 .unwrap_or_else(|poisoned| poisoned.into_inner())
9843 .records = previous_records;
9844 return Err(persistence_failure(error));
9845 }
9846 let unavailable = self
9847 .session_records
9848 .lock()
9849 .unwrap_or_else(|poisoned| poisoned.into_inner())
9850 .records
9851 .values()
9852 .filter(|record| &record.workspace_id == workspace_id)
9853 .cloned()
9854 .collect::<Vec<_>>();
9855 drop(transaction);
9856 self.publish(NodeEvent::WorkspaceRemoved {
9857 workspace_id: workspace_id.clone(),
9858 });
9859 for record in unavailable {
9860 self.publish_record(record);
9861 }
9862 Ok(())
9863 }
9864
9865 async fn create_worktree(
9866 &self,
9867 source_workspace_id: WorkspaceId,
9868 workspace_id: WorkspaceId,
9869 target_root: String,
9870 branch: String,
9871 base: Option<String>,
9872 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9873 self.require_worktree_service(&source_workspace_id)?;
9874 validate_workspace_request_root(&target_root)?;
9875 validate_git_revision("worktree base", base.as_deref())?;
9876 let source_root = self.workspace_root(&source_workspace_id)?;
9877 {
9878 let workspaces = self
9879 .workspaces
9880 .read()
9881 .unwrap_or_else(|poisoned| poisoned.into_inner());
9882 if workspaces.contains_key(&workspace_id) {
9883 return Err(failure(
9884 NodeFailureCode::DuplicateWorkspaceId,
9885 "workspace ID is already registered",
9886 ));
9887 }
9888 if let Some((existing_id, _)) = workspaces
9889 .iter()
9890 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9891 {
9892 return Err(failure(
9893 NodeFailureCode::DuplicateWorkspaceRoot,
9894 &format!("workspace root is already registered as '{existing_id}'"),
9895 ));
9896 }
9897 }
9898 let mut worktree = create_git_worktree(
9899 &source_root,
9900 &target_root,
9901 &branch,
9902 base.as_deref(),
9903 )
9904 .await
9905 .map_err(git_worktree_failure)?;
9906 let workspace = match self
9907 .register_workspace(workspace_id, worktree.path.clone())
9908 .await
9909 {
9910 Ok(workspace) => workspace,
9911 Err(registration_error) => {
9912 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9913 return Err(failure(
9914 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9915 "worktree registration failed and compensation requires recovery",
9916 ));
9917 }
9918 return Err(failure(
9919 registration_error.code,
9920 ®istration_error.message,
9921 ));
9922 }
9923 };
9924 worktree.workspace_id = Some(workspace.workspace_id.clone());
9925 Ok((protocol_worktree(worktree), workspace))
9926 }
9927
9928 async fn remove_worktree(
9929 &self,
9930 source_workspace_id: WorkspaceId,
9931 target_root: String,
9932 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9933 self.require_worktree_service(&source_workspace_id)?;
9934 validate_workspace_request_root(&target_root)?;
9935 let source_root = self.workspace_root(&source_workspace_id)?;
9936 let target_root = normalize_worktree_removal_target(&target_root)
9937 .map_err(git_worktree_failure)?;
9938 let target = list_git_worktrees(&source_root)
9939 .await
9940 .map_err(git_worktree_failure)?
9941 .into_iter()
9942 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9943 .ok_or_else(|| {
9944 failure(
9945 NodeFailureCode::WorktreeProtected,
9946 "refusing to remove a path that is not in Git's worktree listing",
9947 )
9948 })?;
9949 let registered_workspace_id = {
9950 let workspaces = self
9951 .workspaces
9952 .read()
9953 .unwrap_or_else(|poisoned| poisoned.into_inner());
9954 let matched = workspaces
9955 .iter()
9956 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9957 .map(|(workspace_id, _)| workspace_id.clone());
9958 if matched.is_some() && workspaces.len() == 1 {
9959 return Err(failure(
9960 NodeFailureCode::LastWorkspace,
9961 "node must retain at least one workspace",
9962 ));
9963 }
9964 matched
9965 };
9966 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9967 let bindings = self
9968 .session_bindings
9969 .lock()
9970 .unwrap_or_else(|poisoned| poisoned.into_inner());
9971 if bindings
9972 .values()
9973 .any(|binding| &binding.workspace_id == workspace_id)
9974 {
9975 return Err(failure(
9976 NodeFailureCode::WorkspaceBusy,
9977 "worktree workspace retains a bound session; remove every session first",
9978 ));
9979 }
9980 }
9981 remove_git_worktree(&source_root, &target.path)
9982 .await
9983 .map_err(git_worktree_failure)?;
9984 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9985 self.unregister_workspace(workspace_id)
9986 .map_err(|error| failure(error.code, &error.message))?;
9987 }
9988 Ok(registered_workspace_id)
9989 }
9990
9991 async fn cleanup_managed_worktree(
9992 &self,
9993 lease_id: &ManagedWorktreeLeaseId,
9994 explicit: bool,
9995 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9996 let lease = self.managed_worktrees.lock()
9997 .unwrap_or_else(|poisoned| poisoned.into_inner())
9998 .get(lease_id)
9999 .cloned()
10000 .ok_or_else(|| failure(
10001 NodeFailureCode::UnknownManagedWorktreeLease,
10002 "managed worktree lease does not exist",
10003 ))?;
10004 if lease.state == ManagedWorktreeLeaseState::Removed {
10005 return Ok(lease.snapshot());
10006 }
10007 if lease.has_holders() {
10008 return Err(failure(
10009 NodeFailureCode::ManagedWorktreeBusy,
10010 "managed worktree lease still has a session or durable-record holder",
10011 ));
10012 }
10013 if self
10014 .materializations
10015 .lock()
10016 .unwrap_or_else(|poisoned| poisoned.into_inner())
10017 .values()
10018 .any(|record| record.managed_lease_id() == Some(lease_id))
10019 {
10020 return Err(failure(
10021 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10022 "managed worktree cleanup is blocked by session environment ownership",
10023 ));
10024 }
10025 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10026 let snapshot = {
10027 let mut registry = self.managed_worktrees.lock()
10028 .unwrap_or_else(|poisoned| poisoned.into_inner());
10029 let current = registry.get_mut(lease_id)
10030 .expect("managed worktree lease remains present");
10031 current.state = ManagedWorktreeLeaseState::Retained;
10032 current.cleanup_failure = None;
10033 current.updated_at_unix_ms = unix_time_ms();
10034 current.snapshot()
10035 };
10036 if self.persist_state().is_err() {
10037 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10038 }
10039 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10040 return Ok(snapshot);
10041 }
10042 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10043 if profile.revision() != &lease.profile_revision {
10044 self.mark_managed_recovery_required(
10045 lease_id,
10046 ManagedWorktreeCleanupFailure::OwnershipConflict,
10047 );
10048 return Err(failure(
10049 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10050 "managed worktree profile revision changed",
10051 ));
10052 }
10053 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10054 self.mark_managed_recovery_required(
10055 lease_id,
10056 ManagedWorktreeCleanupFailure::OwnershipConflict,
10057 );
10058 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10059 })?;
10060 let source_root = self.workspace_root(&lease.source_workspace_id)?;
10061 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10062 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10063 managed_git_worktree_failure(error)
10064 })?;
10065 let target = listed.iter()
10066 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10067 if let Some(target) = target {
10068 if target.is_main || target.is_bare || !exact_owned_worktree(
10069 &lease,
10070 &target.path,
10071 target.branch.as_deref(),
10072 &target.head,
10073 ) {
10074 self.mark_managed_recovery_required(
10075 lease_id,
10076 ManagedWorktreeCleanupFailure::OwnershipConflict,
10077 );
10078 return Err(failure(
10079 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10080 "Git worktree identity no longer matches the managed lease",
10081 ));
10082 }
10083 if target.prunable {
10084 self.mark_managed_cleanup_blocked(
10085 lease_id,
10086 ManagedWorktreeCleanupFailure::Prunable,
10087 );
10088 return Err(failure(
10089 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10090 "managed worktree is prunable and requires recovery",
10091 ));
10092 }
10093 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10094 let failure_kind = match error.kind {
10095 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10096 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10097 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10098 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10099 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10100 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10101 };
10102 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10103 self.mark_managed_recovery_required(lease_id, failure_kind);
10104 } else {
10105 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10106 }
10107 return Err(managed_git_worktree_failure(error));
10108 }
10109 } else {
10110 match std::fs::symlink_metadata(&lease.target_root) {
10111 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10112 Ok(_) => {
10113 self.mark_managed_recovery_required(
10114 lease_id,
10115 ManagedWorktreeCleanupFailure::OwnershipConflict,
10116 );
10117 return Err(failure(
10118 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10119 "managed target exists but is not owned by Git",
10120 ));
10121 }
10122 Err(_) => {
10123 self.mark_managed_cleanup_blocked(
10124 lease_id,
10125 ManagedWorktreeCleanupFailure::Backend,
10126 );
10127 return Err(failure(
10128 NodeFailureCode::BackendOperationFailed,
10129 "managed target identity could not be inspected",
10130 ));
10131 }
10132 }
10133 }
10134 let registered = self.workspaces.read()
10135 .unwrap_or_else(|poisoned| poisoned.into_inner())
10136 .contains_key(&lease.workspace_id);
10137 if registered {
10138 self.unregister_workspace(&lease.workspace_id)?;
10139 }
10140 let removed = {
10141 self.managed_worktrees.lock()
10142 .unwrap_or_else(|poisoned| poisoned.into_inner())
10143 .tombstone(lease_id, unix_time_ms())
10144 .expect("managed worktree lease remains present during cleanup")
10145 };
10146 self.persist_state().map_err(persistence_failure)?;
10147 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10148 Ok(removed.snapshot())
10149 }
10150
10151 fn mark_managed_cleanup_blocked(
10152 &self,
10153 lease_id: &ManagedWorktreeLeaseId,
10154 failure_kind: ManagedWorktreeCleanupFailure,
10155 ) {
10156 let snapshot = {
10157 let mut registry = self.managed_worktrees.lock()
10158 .unwrap_or_else(|poisoned| poisoned.into_inner());
10159 let Some(lease) = registry.get_mut(lease_id) else { return };
10160 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10161 lease.cleanup_failure = Some(failure_kind);
10162 lease.updated_at_unix_ms = unix_time_ms();
10163 lease.snapshot()
10164 };
10165 let _ = self.persist_state();
10166 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10167 }
10168
10169 fn require_worktree_service(
10170 &self,
10171 source_workspace_id: &WorkspaceId,
10172 ) -> Result<(), NodeFailure> {
10173 if self.managed_worktrees.lock()
10174 .unwrap_or_else(|poisoned| poisoned.into_inner())
10175 .lease_for_workspace(source_workspace_id)
10176 .is_some()
10177 {
10178 return Err(failure(
10179 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10180 "managed worktree targets are internal-only workspaces",
10181 ));
10182 }
10183 match self
10184 .worktree_service_modes
10185 .get(source_workspace_id)
10186 .copied()
10187 .unwrap_or(WorktreeServiceMode::Manual)
10188 {
10189 WorktreeServiceMode::Manual => Ok(()),
10190 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10191 NodeFailureCode::UnsupportedSpawnCapability,
10192 "Git worktree service mode is unavailable in this node build",
10193 )),
10194 }
10195 }
10196
10197 fn managed_reservation_conflict(
10198 &self,
10199 workspace_id: Option<&WorkspaceId>,
10200 root: Option<&str>,
10201 ) -> bool {
10202 self.managed_worktrees.lock()
10203 .unwrap_or_else(|poisoned| poisoned.into_inner())
10204 .active_records()
10205 .any(|lease| {
10206 workspace_id.is_some_and(|workspace_id| {
10207 workspace_id == &lease.source_workspace_id
10208 || workspace_id == &lease.workspace_id
10209 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10210 })
10211 }
10212
10213 fn reject_managed_reservation(
10214 &self,
10215 workspace_id: Option<&WorkspaceId>,
10216 root: Option<&str>,
10217 ) -> Result<(), NodeFailure> {
10218 if self.managed_reservation_conflict(workspace_id, root) {
10219 Err(failure(
10220 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10221 "managed worktree ownership may only be mutated through its lease cleanup",
10222 ))
10223 } else {
10224 Ok(())
10225 }
10226 }
10227
10228 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10229 self.validate_address(address)?;
10230 self.workspace_root(&address.workspace_id)
10231 }
10232
10233 fn require_session_runtime_policy(
10267 &self,
10268 address: &SessionAddress,
10269 requirement: ProviderRuntimeRequirement,
10270 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10271 let runtime_policy = {
10272 let bindings = self
10273 .session_bindings
10274 .lock()
10275 .unwrap_or_else(|poisoned| poisoned.into_inner());
10276 let binding = bindings
10277 .get(&address.session.instance_id)
10278 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10279 if binding.workspace_id != address.workspace_id {
10280 return Err(failure(
10281 NodeFailureCode::SessionWorkspaceMismatch,
10282 "session belongs to another workspace",
10283 ));
10284 }
10285 if binding.generation != address.session.generation {
10286 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10287 }
10288 binding.runtime_policy
10289 };
10290 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10291 let snapshot = self.internal_session_snapshot(address)?;
10292 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10293 && snapshot.provider.lead_activity != ProviderActivity::Idle
10294 {
10295 return Err(failure(
10296 NodeFailureCode::TurnInFlight,
10297 "session already has a turn in flight; wait for it to complete before sending another prompt",
10298 ));
10299 }
10300 }
10301 require_policy(runtime_policy, false, false, requirement)
10302 .map(|()| runtime_policy)
10303 .map_err(|_| failure(
10304 NodeFailureCode::UnsupportedCapability,
10305 "session runtime policy does not admit this semantic operation",
10306 ))
10307 }
10308
10309 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10310 let mut bindings = self
10311 .session_bindings
10312 .lock()
10313 .unwrap_or_else(|poisoned| poisoned.into_inner());
10314 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10315 binding.workspace_id == address.workspace_id
10316 && binding.generation == address.session.generation
10317 }) {
10318 let removed = bindings.remove(&address.session.instance_id);
10319 self.clear_terminal_frame_watermark(address);
10320 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10321 return removed;
10322 }
10323 None
10324 }
10325
10326 fn cleanup_session_owned_materialization(
10327 &self,
10328 address: &SessionAddress,
10329 binding: &SessionBinding,
10330 ) -> Result<(), NodeFailure> {
10331 let Some(id) = binding.materialization_id.as_ref() else {
10332 return Ok(());
10333 };
10334 let owner = self
10335 .materializations
10336 .lock()
10337 .unwrap_or_else(|poisoned| poisoned.into_inner())
10338 .get(id)
10339 .map(|record| record.owner().clone());
10340 match owner {
10341 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10342 Some(MaterializationOwner::Session {
10343 incarnation_id,
10344 instance_id,
10345 generation,
10346 }) if incarnation_id == self.incarnation_id
10347 && instance_id == address.session.instance_id
10348 && generation == address.session.generation =>
10349 {
10350 self.cleanup_materialization(id)
10351 }
10352 Some(MaterializationOwner::Session { .. }) => Err(failure(
10353 NodeFailureCode::BackendOperationFailed,
10354 "session environment ownership requires recovery",
10355 )),
10356 }
10357 }
10358
10359 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10360 let mut watermarks = self
10361 .terminal_frame_watermarks
10362 .lock()
10363 .unwrap_or_else(|poisoned| poisoned.into_inner());
10364 if watermarks
10365 .get(&address.session.instance_id)
10366 .is_some_and(|(current, _)| current == address)
10367 {
10368 watermarks.remove(&address.session.instance_id);
10369 }
10370 }
10371
10372 #[cfg(test)]
10373 fn address_for(
10374 &self,
10375 instance_id: AgentInstanceId,
10376 generation: SessionGeneration,
10377 ) -> Option<SessionAddress> {
10378 let binding = self.session_bindings
10379 .lock()
10380 .unwrap_or_else(|poisoned| poisoned.into_inner())
10381 .get(&instance_id)
10382 .cloned()?;
10383 (binding.generation == generation).then_some(SessionAddress {
10384 workspace_id: binding.workspace_id,
10385 session: SessionKey { instance_id, generation },
10386 })
10387 }
10388
10389 fn arm_resume(
10390 &self,
10391 address: &SessionAddress,
10392 command_id: CommandId,
10393 runtime_policy: ProviderRuntimePolicy,
10394 ) -> Result<(), NodeFailure> {
10395 let mut bindings = self
10396 .session_bindings
10397 .lock()
10398 .unwrap_or_else(|poisoned| poisoned.into_inner());
10399 let binding = bindings
10400 .get_mut(&address.session.instance_id)
10401 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10402 if binding.workspace_id != address.workspace_id {
10403 return Err(failure(
10404 NodeFailureCode::SessionWorkspaceMismatch,
10405 "session belongs to another workspace",
10406 ));
10407 }
10408 if binding.generation != address.session.generation {
10409 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10410 }
10411 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10412 Ok(())
10413 }
10414
10415 fn clear_armed_resume(&self, address: &SessionAddress) {
10416 let mut bindings = self
10417 .session_bindings
10418 .lock()
10419 .unwrap_or_else(|poisoned| poisoned.into_inner());
10420 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10421 if binding.workspace_id == address.workspace_id
10422 && binding.generation == address.session.generation
10423 {
10424 binding.pending_resume = None;
10425 }
10426 }
10427 }
10428
10429 fn publish_control(&self, event: ControlEvent) {
10430 let revokes_harness_mcp = matches!(
10431 &event.event,
10432 ControlEventKind::Exited { .. }
10433 | ControlEventKind::Failed { .. }
10434 | ControlEventKind::Removed
10435 );
10436 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10437 let mut bindings = self
10438 .session_bindings
10439 .lock()
10440 .unwrap_or_else(|poisoned| poisoned.into_inner());
10441 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10442 return;
10443 };
10444 let previous_address = SessionAddress {
10445 workspace_id: binding.workspace_id.clone(),
10446 session: SessionKey {
10447 instance_id: event.instance_id,
10448 generation: binding.generation,
10449 },
10450 };
10451 let mut resume_command_rejected = false;
10452 if binding.generation == event.generation {
10453 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10454 let rejected_resume = matches!(
10455 &event.event,
10456 ControlEventKind::CommandRejected { .. }
10457 ) && event.command_id == Some(pending_command_id);
10458 let resume_failed = matches!(
10459 &event.event,
10460 ControlEventKind::ResumeDenied { .. }
10461 | ControlEventKind::ResumeFailed { .. }
10462 );
10463 if pending_generation == binding.generation
10464 && (rejected_resume || resume_failed)
10465 {
10466 resume_command_rejected = rejected_resume;
10467 binding.pending_resume = None;
10468 }
10469 }
10470 } else {
10471 let expected = binding.generation.0.checked_add(1);
10472 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10473 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10474 generation == binding.generation
10475 })
10476 || expected != Some(event.generation.0)
10477 || !authorized
10478 {
10479 return;
10480 }
10481 let (_, _, runtime_policy) = binding
10482 .pending_resume
10483 .expect("authorized resume retains its pending runtime policy");
10484 binding.generation = event.generation;
10485 binding.runtime_policy = runtime_policy;
10486 binding.pending_resume = None;
10487 }
10488 (
10489 SessionAddress {
10490 workspace_id: binding.workspace_id.clone(),
10491 session: SessionKey {
10492 instance_id: event.instance_id,
10493 generation: event.generation,
10494 },
10495 },
10496 (previous_address.session.generation != event.generation)
10497 .then_some(previous_address),
10498 binding.record_id.clone(),
10499 binding.runtime_policy.provider_session_identity,
10500 resume_command_rejected,
10501 )
10502 };
10503 if let Some(previous_address) = previous_address.as_ref() {
10504 self.clear_terminal_frame_watermark(previous_address);
10505 }
10506 if let Some(record_id) = record_id {
10507 self.reconcile_managed_record(
10508 &record_id,
10509 &address,
10510 &event,
10511 identity_admitted,
10512 resume_command_rejected,
10513 );
10514 }
10515 match &event.event {
10523 ControlEventKind::Running { process_id } => {
10524 #[cfg(unix)]
10525 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10526 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10527 }
10528 tracing::info!(
10529 node_id = %self.node_id,
10530 workspace_id = %address.workspace_id,
10531 session = ?address.session,
10532 pid = ?process_id,
10533 "session process is running",
10534 );
10535 }
10536 ControlEventKind::Exited { exit_code, forced } => {
10537 #[cfg(unix)]
10538 if let Some(proxy) = self.signer_proxy.get().cloned() {
10539 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10540 if proxy.has_session(&key) {
10541 std::thread::spawn(move || proxy.release(&key));
10543 }
10544 }
10545 tracing::info!(
10546 node_id = %self.node_id,
10547 workspace_id = %address.workspace_id,
10548 session = ?address.session,
10549 exit_code = ?exit_code,
10550 forced = forced,
10551 "session process exited",
10552 );
10553 }
10554 ControlEventKind::Failed { message } => {
10555 tracing::warn!(
10556 node_id = %self.node_id,
10557 workspace_id = %address.workspace_id,
10558 session = ?address.session,
10559 cause = %message,
10560 "session failed",
10561 );
10562 }
10563 ControlEventKind::CommandRejected { message } => {
10564 tracing::warn!(
10565 node_id = %self.node_id,
10566 workspace_id = %address.workspace_id,
10567 session = ?address.session,
10568 cause = %message,
10569 "session command rejected",
10570 );
10571 }
10572 ControlEventKind::Removed => {
10573 tracing::info!(
10574 node_id = %self.node_id,
10575 workspace_id = %address.workspace_id,
10576 session = ?address.session,
10577 "session removed",
10578 );
10579 }
10580 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10581 tracing::info!(
10582 node_id = %self.node_id,
10583 workspace_id = %address.workspace_id,
10584 session = ?address.session,
10585 provider_session = ?provider_session,
10586 "session resume authorized",
10587 );
10588 }
10589 ControlEventKind::Resumed { session: provider_session, process_id } => {
10590 tracing::info!(
10591 node_id = %self.node_id,
10592 workspace_id = %address.workspace_id,
10593 session = ?address.session,
10594 provider_session = ?provider_session,
10595 pid = ?process_id,
10596 "session resumed",
10597 );
10598 }
10599 ControlEventKind::ResumeDenied { reason } => {
10600 tracing::warn!(
10601 node_id = %self.node_id,
10602 workspace_id = %address.workspace_id,
10603 session = ?address.session,
10604 cause = %reason,
10605 "session resume denied",
10606 );
10607 }
10608 ControlEventKind::ResumeFailed { message } => {
10609 tracing::warn!(
10610 node_id = %self.node_id,
10611 workspace_id = %address.workspace_id,
10612 session = ?address.session,
10613 cause = %message,
10614 "session resume failed",
10615 );
10616 }
10617 _ => {}
10618 }
10619 self.apply_pending_acp_options(&address, &event);
10620 let stream_chunk = agent_stream_chunk(&event);
10621 self.publish(NodeEvent::Control {
10622 address: address.clone(),
10623 event,
10624 });
10625 if let Some(chunk) = stream_chunk {
10626 self.publish_agent_stream_chunk(address.clone(), chunk);
10627 }
10628 if revokes_harness_mcp {
10629 SessionLifecycleHook::on_session_ended(self, &address);
10630 }
10631 }
10632
10633 fn reconcile_managed_record(
10634 &self,
10635 record_id: &SessionRecordId,
10636 address: &SessionAddress,
10637 event: &ControlEvent,
10638 identity_admitted: bool,
10639 resume_command_rejected: bool,
10640 ) {
10641 let _transaction = self
10642 .state_transaction
10643 .lock()
10644 .unwrap_or_else(|poisoned| poisoned.into_inner());
10645 let observed_identity = match &event.event {
10646 ControlEventKind::ProviderEvent {
10647 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10648 ..
10649 } if identity_admitted => Some(identity.clone()),
10650 _ => None,
10651 };
10652 let identity_was_observed = observed_identity.is_some();
10653 let replacement_id = observed_identity
10654 .as_ref()
10655 .and_then(|_| self.allocate_record_id().ok());
10656 let mut upserts = Vec::new();
10657 let mut removals = Vec::new();
10658 let mut rebind = None;
10659 let original_records;
10660 let original_materializations = self
10661 .materializations
10662 .lock()
10663 .unwrap_or_else(|poisoned| poisoned.into_inner())
10664 .clone();
10665 {
10666 let mut records = self
10667 .session_records
10668 .lock()
10669 .unwrap_or_else(|poisoned| poisoned.into_inner());
10670 original_records = records.records.clone();
10671 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10672 return;
10673 };
10674 if let Some(identity) = observed_identity {
10675 let existing_id = records
10676 .records
10677 .values()
10678 .find(|record| {
10679 record.record_id != *record_id
10680 && record.provider_session.as_ref().is_some_and(|record_identity| {
10681 session_registry::same_provider_session(
10682 &record.provider,
10683 record_identity,
10684 ¤t_snapshot.provider,
10685 &identity,
10686 )
10687 })
10688 })
10689 .map(|record| record.record_id.clone());
10690 if let Some(existing_id) = existing_id {
10691 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10692 record.mode == current_snapshot.mode
10693 && record.workspace_id == current_snapshot.workspace_id
10694 && record.environment_profile == current_snapshot.environment_profile
10695 && record.bundle == current_snapshot.bundle
10696 && record.context_id == current_snapshot.context_id
10697 && record.context == current_snapshot.context
10698 && record
10699 .canonical_root
10700 .as_utf8()
10701 .zip(current_snapshot.canonical_root.as_utf8())
10702 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10703 });
10704 if !scope_matches {
10705 if let Some(current) = records.records.get_mut(record_id) {
10706 current.state = ManagedSessionState::Unavailable;
10707 current.last_error =
10708 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10709 current.updated_at_unix_ms = unix_time_ms();
10710 upserts.push(current.clone());
10711 }
10712 } else {
10713 let existing_is_active = records
10714 .records
10715 .get(&existing_id)
10716 .is_some_and(|record| record.active_session.is_some());
10717 if existing_is_active {
10718 if let Some(current) = records.records.get_mut(record_id) {
10719 current.state = ManagedSessionState::Unavailable;
10720 current.last_error =
10721 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10722 current.updated_at_unix_ms = unix_time_ms();
10723 upserts.push(current.clone());
10724 }
10725 } else {
10726 if current_snapshot.provider_session.is_some() {
10727 if let Some(current) = records.records.get_mut(record_id) {
10728 current.active_session = None;
10729 current.state = ManagedSessionState::Dormant;
10730 current.updated_at_unix_ms = unix_time_ms();
10731 upserts.push(current.clone());
10732 }
10733 } else {
10734 records.records.remove(record_id);
10735 removals.push(record_id.clone());
10736 }
10737 if let Some(existing) = records.records.get_mut(&existing_id) {
10738 existing.provider_session = Some(identity.clone());
10739 existing.active_session = Some(address.clone());
10740 existing.state = ManagedSessionState::Live;
10741 existing.updated_at_unix_ms = unix_time_ms();
10742 existing.last_error = None;
10743 upserts.push(existing.clone());
10744 }
10745 rebind = Some(existing_id);
10746 }
10747 }
10748 } else if current_snapshot
10749 .provider_session
10750 .as_ref()
10751 .is_some_and(|current| {
10752 !session_registry::same_provider_session(
10753 ¤t_snapshot.provider,
10754 current,
10755 ¤t_snapshot.provider,
10756 &identity,
10757 )
10758 })
10759 {
10760 if let Some(current) = records.records.get_mut(record_id) {
10761 current.active_session = None;
10762 if current.bundle.is_some() || current.context.is_some() {
10763 let had_bundle = current.bundle.is_some();
10764 current.provider_session = None;
10765 current.bundle = None;
10766 current.context_id = None;
10767 current.context = None;
10768 current.state = ManagedSessionState::Unavailable;
10769 current.last_error = Some(if had_bundle {
10770 "bundle-unavailable".to_owned()
10771 } else {
10772 "context-unavailable".to_owned()
10773 });
10774 } else {
10775 current.state = ManagedSessionState::Dormant;
10776 }
10777 current.updated_at_unix_ms = unix_time_ms();
10778 upserts.push(current.clone());
10779 }
10780 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10781 if let Some(current) = records.records.get_mut(record_id) {
10782 current.state = ManagedSessionState::Unavailable;
10783 current.last_error =
10784 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10785 current.updated_at_unix_ms = unix_time_ms();
10786 upserts.push(current.clone());
10787 }
10788 } else if let Some(new_record_id) = replacement_id {
10789 let now = unix_time_ms();
10790 let replacement = ManagedSessionRecord {
10791 record_id: new_record_id.clone(),
10792 display_name: format!(
10793 "{} #{}",
10794 current_snapshot.provider.as_str(),
10795 records
10796 .records
10797 .values()
10798 .filter(|record| {
10799 record.provider == current_snapshot.provider
10800 })
10801 .count()
10802 .saturating_add(1),
10803 ),
10804 provider: current_snapshot.provider.clone(),
10805 mode: current_snapshot.mode,
10806 state: ManagedSessionState::Live,
10807 workspace_id: current_snapshot.workspace_id,
10808 canonical_root: current_snapshot.canonical_root,
10809 provider_session: Some(identity),
10810 active_session: Some(address.clone()),
10811 environment_profile: current_snapshot.environment_profile,
10812 bundle: current_snapshot.bundle,
10813 context_id: current_snapshot.context_id,
10814 context: current_snapshot.context,
10815 exported_context: None,
10821 task_binding: current_snapshot.task_binding,
10822 created_at_unix_ms: now,
10823 updated_at_unix_ms: now,
10824 last_error: None,
10825 };
10826 records
10827 .records
10828 .insert(new_record_id.clone(), replacement.clone());
10829 upserts.push(replacement);
10830 rebind = Some(new_record_id);
10831 } else if let Some(current) = records.records.get_mut(record_id) {
10832 current.state = ManagedSessionState::Unavailable;
10833 current.last_error =
10834 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10835 upserts.push(current.clone());
10836 }
10837 } else if let Some(current) = records.records.get_mut(record_id) {
10838 current.provider_session = Some(identity);
10839 current.active_session = Some(address.clone());
10840 current.state = ManagedSessionState::Live;
10841 current.updated_at_unix_ms = unix_time_ms();
10842 current.last_error = None;
10843 upserts.push(current.clone());
10844 }
10845 } else if let Some(current) = records.records.get_mut(record_id) {
10846 if resume_command_rejected {
10847 current.active_session = None;
10848 current.state = detached_record_state(current);
10849 current.updated_at_unix_ms = unix_time_ms();
10850 current.last_error =
10851 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10852 upserts.push(current.clone());
10853 } else {
10854 match &event.event {
10855 ControlEventKind::Running { .. }
10856 | ControlEventKind::ResumeAuthorized { .. }
10857 | ControlEventKind::Resumed { .. } => {
10858 current.active_session = Some(address.clone());
10859 current.state = active_record_state(
10860 identity_admitted,
10861 current.provider_session.is_some(),
10862 );
10863 current.updated_at_unix_ms = unix_time_ms();
10864 current.last_error = None;
10865 upserts.push(current.clone());
10866 }
10867 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10868 current.active_session = None;
10869 current.state = detached_record_state(current);
10870 current.updated_at_unix_ms = unix_time_ms();
10871 upserts.push(current.clone());
10872 }
10873 ControlEventKind::Failed { message }
10874 | ControlEventKind::ResumeFailed { message } => {
10875 current.active_session = None;
10876 current.state = detached_record_state(current);
10877 current.updated_at_unix_ms = unix_time_ms();
10878 current.last_error = Some(
10879 session_registry::sanitized_record_error_summary(message),
10880 );
10881 upserts.push(current.clone());
10882 }
10883 ControlEventKind::ResumeDenied { reason } => {
10884 current.active_session = None;
10885 current.state = detached_record_state(current);
10886 current.updated_at_unix_ms = unix_time_ms();
10887 current.last_error = Some(
10888 session_registry::sanitized_record_error_summary(reason),
10889 );
10890 upserts.push(current.clone());
10891 }
10892 _ => {}
10893 }
10894 }
10895 }
10896 }
10897 let materialization_owner_record = identity_was_observed.then(|| {
10898 rebind.clone().unwrap_or_else(|| record_id.clone())
10899 }).and_then(|candidate| {
10900 self.session_records
10901 .lock()
10902 .unwrap_or_else(|poisoned| poisoned.into_inner())
10903 .records
10904 .get(&candidate)
10905 .filter(|record| {
10906 record.provider_session.is_some()
10907 && record.active_session.as_ref() == Some(address)
10908 })
10909 .map(|_| candidate)
10910 });
10911 if let Some(owner_record_id) = materialization_owner_record {
10912 let materialization_id = self
10913 .session_bindings
10914 .lock()
10915 .unwrap_or_else(|poisoned| poisoned.into_inner())
10916 .get(&address.session.instance_id)
10917 .and_then(|binding| binding.materialization_id.clone());
10918 if let Some(materialization_id) = materialization_id {
10919 let mut transfer_failed = false;
10920 {
10921 let mut materializations = self
10922 .materializations
10923 .lock()
10924 .unwrap_or_else(|poisoned| poisoned.into_inner());
10925 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10926 match ownership.owner() {
10927 MaterializationOwner::Session {
10928 incarnation_id,
10929 instance_id,
10930 generation,
10931 } if *incarnation_id == self.incarnation_id
10932 && *instance_id == address.session.instance_id
10933 && *generation == address.session.generation =>
10934 {
10935 transfer_failed = ownership
10936 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10937 .is_err();
10938 }
10939 MaterializationOwner::Record { record_id }
10940 if record_id == &owner_record_id => {}
10941 MaterializationOwner::Record { record_id } => {
10942 let expected = record_id.clone();
10943 transfer_failed = ownership
10944 .transfer_record_owner(
10945 &expected,
10946 owner_record_id.clone(),
10947 unix_time_ms(),
10948 )
10949 .is_err();
10950 }
10951 _ => transfer_failed = true,
10952 }
10953 if transfer_failed {
10954 let _ = ownership.mark_recovery_required(unix_time_ms());
10955 }
10956 } else {
10957 transfer_failed = true;
10958 }
10959 }
10960 if transfer_failed {
10961 if let Some(record) = self
10962 .session_records
10963 .lock()
10964 .unwrap_or_else(|poisoned| poisoned.into_inner())
10965 .records
10966 .get_mut(&owner_record_id)
10967 {
10968 record.state = ManagedSessionState::Unavailable;
10969 record.last_error = Some("environment-profile-unavailable".to_owned());
10970 record.updated_at_unix_ms = unix_time_ms();
10971 upserts.push(record.clone());
10972 }
10973 }
10974 }
10975 }
10976 if let Some(new_record_id) = rebind.clone() {
10977 if let Some(binding) = self
10978 .session_bindings
10979 .lock()
10980 .unwrap_or_else(|poisoned| poisoned.into_inner())
10981 .get_mut(&address.session.instance_id)
10982 {
10983 binding.record_id = Some(new_record_id);
10984 }
10985 }
10986 if upserts.is_empty() && removals.is_empty() {
10987 return;
10988 }
10989 if self.persist_state_locked().is_err() {
10990 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10991 let mut records = self
10992 .session_records
10993 .lock()
10994 .unwrap_or_else(|poisoned| poisoned.into_inner());
10995 records.records = original_records;
10996 *self
10997 .materializations
10998 .lock()
10999 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11000 if let Some(current) = records.records.get_mut(record_id) {
11001 current.state = ManagedSessionState::Unavailable;
11002 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11003 current.updated_at_unix_ms = unix_time_ms();
11004 upserts = vec![current.clone()];
11005 } else {
11006 upserts.clear();
11007 }
11008 removals.clear();
11009 drop(records);
11010 if let Some(binding) = self
11011 .session_bindings
11012 .lock()
11013 .unwrap_or_else(|poisoned| poisoned.into_inner())
11014 .get_mut(&address.session.instance_id)
11015 {
11016 binding.record_id = Some(record_id.clone());
11017 }
11018 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11019 }
11020 for record in upserts {
11021 self.publish_record(record);
11022 }
11023 for record_id in removals {
11024 self.publish(NodeEvent::SessionRecordRemoved { record_id });
11025 }
11026 }
11027
11028 fn snapshot(&self) -> NodeSnapshot {
11029 let control = self.handle.snapshot();
11030 let bindings = self
11031 .session_bindings
11032 .lock()
11033 .unwrap_or_else(|poisoned| poisoned.into_inner())
11034 .clone();
11035 let workspace_roots = self
11036 .workspaces
11037 .read()
11038 .unwrap_or_else(|poisoned| poisoned.into_inner())
11039 .clone();
11040 let environment_profiles = self
11041 .environment_profiles
11042 .read()
11043 .unwrap_or_else(|poisoned| poisoned.into_inner());
11044 let mut workspaces = workspace_roots
11045 .iter()
11046 .map(|(workspace_id, canonical_root)| {
11047 (
11048 workspace_id.clone(),
11049 WorkspaceSnapshot {
11050 workspace_id: workspace_id.clone(),
11051 canonical_root: opaque_windows_path(canonical_root.clone()),
11052 sessions: Vec::new(),
11053 worktree_service_mode: Some(
11054 self.worktree_service_modes
11055 .get(workspace_id)
11056 .copied()
11057 .unwrap_or(WorktreeServiceMode::Manual),
11058 ),
11059 managed_worktree_profiles: Some(WorktreeProfileInventory {
11060 profiles: self
11061 .managed_worktree_profiles
11062 .get(workspace_id)
11063 .into_iter()
11064 .flat_map(BTreeMap::values)
11065 .map(|profile| ManagedWorktreeProfileSummary {
11066 id: profile.profile_id().clone(),
11067 revision: profile.revision().clone(),
11068 retention: profile.retention(),
11069 })
11070 .collect(),
11071 }),
11072 },
11073 )
11074 })
11075 .collect::<BTreeMap<_, _>>();
11076 let mut agent_progress = Vec::new();
11077 for session in &control.sessions {
11078 let Some(binding) = bindings.get(&session.instance_id) else {
11079 continue;
11080 };
11081 if binding.generation != session.generation {
11082 continue;
11083 }
11084 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11085 let address = SessionAddress {
11086 workspace_id: binding.workspace_id.clone(),
11087 session: SessionKey {
11088 instance_id: session.instance_id,
11089 generation: session.generation,
11090 },
11091 };
11092 if let Some(progress) =
11093 agent_progress_from_provider_snapshot(address, &session.provider)
11094 {
11095 agent_progress.push(progress);
11096 }
11097 workspace.sessions.push(session.clone());
11098 }
11099 }
11100 NodeSnapshot {
11101 node_id: self.node_id.clone(),
11102 enabled_providers: self.enabled_providers.clone(),
11103 provider_runtime_statuses: {
11104 let updates = self
11105 .provider_runtime_status_updates
11106 .read()
11107 .unwrap_or_else(|poisoned| poisoned.into_inner());
11108 ProviderRuntimeStatuses::new(
11109 self.provider_runtime_statuses
11110 .iter()
11111 .filter(|status| !updates.contains_key(status.provider()))
11112 .cloned()
11113 .chain(updates.values().cloned()),
11114 )
11115 .expect("refreshed provider runtime inventory remains bounded and unique")
11116 },
11117 workspaces: workspaces.into_values().collect(),
11118 session_records: self
11119 .session_records
11120 .lock()
11121 .unwrap_or_else(|poisoned| poisoned.into_inner())
11122 .records
11123 .values()
11124 .cloned()
11125 .collect(),
11126 managed_worktrees: self.managed_worktrees
11127 .lock()
11128 .unwrap_or_else(|poisoned| poisoned.into_inner())
11129 .snapshots(),
11130 launch_inventory: Some(LaunchInventory {
11131 spawn_profiles: Some(
11132 self.spawn_profiles
11133 .iter()
11134 .filter_map(|profile| {
11135 resolved_spawn_profile_summary(profile, &environment_profiles)
11136 })
11137 .collect(),
11138 ),
11139 bundles: Some(
11140 self.bundle_catalog
11141 .read()
11142 .unwrap_or_else(|poisoned| poisoned.into_inner())
11143 .iter()
11144 .map(NodeBundle::receipt)
11145 .collect(),
11146 ),
11147 network_allowlists: {
11150 let catalog = self
11151 .network_allowlist_catalog
11152 .read()
11153 .unwrap_or_else(|poisoned| poisoned.into_inner());
11154 if catalog.is_empty() {
11155 None
11156 } else {
11157 Some(catalog.ids().cloned().collect())
11158 }
11159 },
11160 }),
11161 agent_progress,
11162 }
11163 }
11164
11165 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11166 assert!(
11167 !matches!(
11168 &event,
11169 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11170 ),
11171 "terminal frame and agent stream events must use their own dedicated live channel",
11172 );
11173 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11174 let sequence = history
11175 .last_sequence
11176 .checked_add(1)
11177 .expect("node event sequence exhausted");
11178 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11179 if let Some(evicted) = history.events.pop_front() {
11180 history.replay_floor_sequence = evicted
11181 .sequence
11182 .checked_add(1)
11183 .expect("node replay floor sequence exhausted");
11184 history.removed_record_providers.remove(&evicted.sequence);
11185 }
11186 }
11187 match &event {
11188 NodeEvent::HarnessMcpReadCall { .. } => {
11189 panic!("transient harness MCP calls must not enter durable event history")
11190 }
11191 NodeEvent::SessionRecordUpserted { record } => {
11192 history
11193 .record_providers
11194 .insert(record.record_id.clone(), record.provider.clone());
11195 }
11196 NodeEvent::SessionRecordRemoved { record_id } => {
11197 if let Some(provider) = history.record_providers.remove(record_id) {
11198 history.removed_record_providers.insert(sequence, provider);
11199 }
11200 }
11201 NodeEvent::ManagedWorktreeUpserted { .. }
11202 | NodeEvent::ManagedWorktreeRemoved { .. }
11203 | NodeEvent::Control { .. }
11204 | NodeEvent::ControllerChanged { .. }
11205 | NodeEvent::SessionRecordHistorySummarized { .. }
11206 | NodeEvent::WorkspaceAdded { .. }
11207 | NodeEvent::WorkspaceRemoved { .. }
11208 | NodeEvent::ResyncRequired { .. }
11209 | NodeEvent::AgentStream { .. }
11210 | NodeEvent::TerminalFrame { .. } => {}
11211 }
11212 let envelope = NodeEventEnvelope { sequence, event };
11213 history.events.push_back(envelope.clone());
11214 history.last_sequence = sequence;
11215 let _ = self.event_tx.send(envelope.clone());
11216 drop(history);
11217 envelope
11218 }
11219
11220 fn publish_transient(&self, event: NodeEvent) -> bool {
11231 assert!(event.requires_harness_mcp_proxy_capability());
11232 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11233 let envelope = NodeEventEnvelope { sequence: 0, event };
11234 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11235 Ok(_) => true,
11236 Err(broadcast::error::SendError(undelivered)) => {
11237 if let Some(NodeEventEnvelope {
11238 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11239 ..
11240 }) = undelivered.first() {
11241 tracing::warn!(
11247 call_id = call_id.as_str(),
11248 content_type = ?request.content_type,
11249 cause = "no-subscribers",
11250 "harness MCP proxy event could not be published",
11251 );
11252 }
11253 false
11254 }
11255 }
11256 }
11257
11258 fn publish_terminal_frames(&self) {
11259 let snapshot = self.handle.snapshot();
11260 let bindings = self
11261 .session_bindings
11262 .lock()
11263 .unwrap_or_else(|poisoned| poisoned.into_inner());
11264 let candidates = snapshot
11265 .sessions
11266 .iter()
11267 .filter_map(|session| {
11268 let binding = bindings.get(&session.instance_id)?;
11269 if binding.generation != session.generation {
11270 return None;
11271 }
11272 let frame = session.terminal_frame.clone()?;
11273 crate::session_signals::feed(
11274 binding.workspace_id.as_str(),
11275 session.instance_id.0,
11276 session.agent_id.as_str(),
11277 &frame.contents,
11278 );
11279 Some((
11280 SessionAddress {
11281 workspace_id: binding.workspace_id.clone(),
11282 session: SessionKey {
11283 instance_id: session.instance_id,
11284 generation: session.generation,
11285 },
11286 },
11287 frame,
11288 ))
11289 })
11290 .collect();
11291 drop(bindings);
11292 self.publish_terminal_frame_candidates(candidates);
11293 }
11294
11295 fn publish_terminal_frame_candidates(
11296 &self,
11297 candidates: Vec<(SessionAddress, TerminalFrame)>,
11298 ) {
11299 let mut watermarks = self
11300 .terminal_frame_watermarks
11301 .lock()
11302 .unwrap_or_else(|poisoned| poisoned.into_inner());
11303 let mut events = Vec::with_capacity(candidates.len());
11304 for (address, frame) in candidates {
11305 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11306 let advanced = watermarks
11307 .get(&address.session.instance_id)
11308 .map_or(true, |(current_address, sequence)| {
11309 current_address != &address || frame.sequence > *sequence
11310 });
11311 if !advanced {
11312 continue;
11313 }
11314 if is_first_frame {
11315 if self.controller_state().is_none() {
11329 tracing::warn!(
11330 node_id = %self.node_id,
11331 workspace_id = %address.workspace_id,
11332 session = ?address.session,
11333 terminal_size = ?frame.size,
11334 "session producing output with no controller attached",
11335 );
11336 } else {
11337 tracing::info!(
11338 node_id = %self.node_id,
11339 workspace_id = %address.workspace_id,
11340 session = ?address.session,
11341 terminal_size = ?frame.size,
11342 "session produced its first output",
11343 );
11344 }
11345 }
11346 watermarks.insert(
11347 address.session.instance_id,
11348 (address.clone(), frame.sequence),
11349 );
11350 if let Some((at, reported)) = self
11351 .pty_input_marks
11352 .lock()
11353 .unwrap_or_else(|poisoned| poisoned.into_inner())
11354 .get_mut(&address.session.instance_id)
11355 {
11356 if !*reported && at.elapsed() < Duration::from_secs(5) {
11358 *reported = true;
11359 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");
11360 }
11361 }
11362 events.push(NodeEvent::TerminalFrame { address, frame });
11363 }
11364 drop(watermarks);
11365 if events.is_empty() {
11366 return;
11367 }
11368 let mut history = self
11369 .history
11370 .lock()
11371 .unwrap_or_else(|poisoned| poisoned.into_inner());
11372 let mut envelopes = Vec::with_capacity(events.len());
11373 for event in events {
11374 let sequence = history
11375 .last_sequence
11376 .checked_add(1)
11377 .expect("node event sequence exhausted");
11378 history.last_sequence = sequence;
11379 envelopes.push(NodeEventEnvelope { sequence, event });
11380 }
11381 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11382 drop(history);
11383 }
11384
11385 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11394 let mut history = self
11395 .history
11396 .lock()
11397 .unwrap_or_else(|poisoned| poisoned.into_inner());
11398 let sequence = history
11399 .last_sequence
11400 .checked_add(1)
11401 .expect("node event sequence exhausted");
11402 history.last_sequence = sequence;
11403 drop(history);
11404 tracing::debug!(
11412 source_sequence = chunk.source_sequence,
11413 kind = agent_stream_chunk_kind_label(&chunk.kind),
11414 envelope_sequence = sequence,
11415 "publishing agent stream chunk",
11416 );
11417 let envelope = NodeEventEnvelope {
11418 sequence,
11419 event: NodeEvent::AgentStream { address, chunk },
11420 };
11421 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11422 }
11423
11424 fn current_sequence(&self) -> u64 {
11425 self.history
11426 .lock()
11427 .unwrap_or_else(|poisoned| poisoned.into_inner())
11428 .last_sequence
11429 }
11430
11431 fn controller_state(&self) -> Option<ControllerState> {
11432 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11433 let lease = controller.as_ref().copied()?;
11434 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11435 if remaining.is_zero() {
11436 *controller = None;
11437 return None;
11438 }
11439 Some(ControllerState {
11440 connection_id: lease.connection_id,
11441 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11442 })
11443 }
11444
11445 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11446 if role != ClientRole::Operator {
11447 tracing::warn!(
11448 node_id = %self.node_id,
11449 connection_id,
11450 "controller lease rejected: observer connections cannot acquire control",
11451 );
11452 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11453 }
11454 let lease_ms = if lease_ms == 0 {
11455 DEFAULT_CONTROLLER_LEASE_MS
11456 } else {
11457 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11458 };
11459 let now = Instant::now();
11460 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11461 if let Some(active) = controller.as_ref() {
11462 if active.connection_id != connection_id && active.expires_at > now {
11463 tracing::warn!(
11464 node_id = %self.node_id,
11465 connection_id,
11466 holder_connection_id = active.connection_id,
11467 "controller lease rejected: another operator holds it",
11468 );
11469 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11470 }
11471 }
11472 let is_new_attach = match controller.as_ref() {
11479 Some(active) => active.connection_id != connection_id,
11480 None => true,
11481 };
11482 *controller = Some(ControllerLease {
11483 connection_id,
11484 expires_at: now + Duration::from_millis(lease_ms),
11485 });
11486 drop(controller);
11487 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11488 if is_new_attach {
11489 tracing::info!(
11490 node_id = %self.node_id,
11491 connection_id,
11492 lease_ms,
11493 "controller lease acquired",
11494 );
11495 } else {
11496 tracing::debug!(
11497 node_id = %self.node_id,
11498 connection_id,
11499 lease_ms,
11500 "controller lease renewed",
11501 );
11502 }
11503 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11504 Ok(state)
11505 }
11506
11507 fn release_controller(&self, connection_id: u64) -> bool {
11508 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11509 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11510 if released {
11511 *controller = None;
11512 }
11513 drop(controller);
11514 if released {
11515 tracing::info!(
11516 node_id = %self.node_id,
11517 connection_id,
11518 "controller lease released",
11519 );
11520 self.publish(NodeEvent::ControllerChanged { controller: None });
11521 }
11522 released
11523 }
11524
11525 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11534 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11535 return;
11536 };
11537 let id = address.session.instance_id;
11538 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11539 return;
11540 };
11541 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11542 .iter()
11543 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11544 .collect();
11545 let (writes, set_model, effort_gap) =
11546 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11547 if set_model {
11548 if let Some(model_id) = model.clone() {
11549 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11550 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11551 }
11552 }
11553 }
11554 for (option_id, value_json) in writes {
11555 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11556 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11557 }
11558 }
11559 if effort_gap {
11560 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11561 }
11562 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11563 }
11564
11565 fn mark_spawn_provisional(&self, address: SessionAddress) {
11566 let id = address.session.instance_id;
11567 self.provisional_spawns
11568 .lock()
11569 .unwrap_or_else(|poisoned| poisoned.into_inner())
11570 .insert(id, (address, Instant::now()));
11571 tracing::info!(
11572 node_id = %self.node_id,
11573 instance_id = id.0,
11574 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11575 "draft spawn; waiting for HQ view",
11576 );
11577 }
11578
11579 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11580 let removed = self
11581 .provisional_spawns
11582 .lock()
11583 .unwrap_or_else(|poisoned| poisoned.into_inner())
11584 .remove(&instance_id);
11585 if removed.is_some() {
11586 tracing::info!(
11587 node_id = %self.node_id,
11588 instance_id = instance_id.0,
11589 "draft spawn claimed by HQ view",
11590 );
11591 }
11592 }
11593
11594 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11595 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11596 let mut map = self
11597 .provisional_spawns
11598 .lock()
11599 .unwrap_or_else(|poisoned| poisoned.into_inner());
11600 let due: Vec<AgentInstanceId> = map
11601 .iter()
11602 .filter(|(_, (_, at))| at.elapsed() >= grace)
11603 .map(|(id, _)| *id)
11604 .collect();
11605 due.into_iter()
11606 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11607 .collect()
11608 }
11609
11610 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11611 let instance_id = address.session.instance_id;
11612 tracing::warn!(
11613 node_id = %self.node_id,
11614 instance_id = instance_id.0,
11615 workspace = %address.workspace_id,
11616 reason = "attach_never_arrived",
11617 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11618 "draft spawn never viewed; stopping PTY",
11619 );
11620 let _ = self.dispatch(ControlCommand::Stop {
11621 instance_id,
11622 force: true,
11623 });
11624 let _ = self.remove_session(&address).await;
11625 }
11626
11627 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11628 if role != ClientRole::Operator {
11629 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11630 }
11631 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11632 let now = Instant::now();
11633 match controller.as_ref().copied() {
11634 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11635 Some(active) if active.expires_at <= now => {
11636 *controller = None;
11637 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11638 }
11639 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11640 }
11641 }
11642
11643 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11644 if !self
11645 .workspaces
11646 .read()
11647 .unwrap_or_else(|poisoned| poisoned.into_inner())
11648 .contains_key(&address.workspace_id)
11649 {
11650 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11651 }
11652 let binding = self
11653 .session_bindings
11654 .lock()
11655 .unwrap_or_else(|poisoned| poisoned.into_inner())
11656 .get(&address.session.instance_id)
11657 .cloned()
11658 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11659 if binding.workspace_id != address.workspace_id {
11660 return Err(failure(
11661 NodeFailureCode::SessionWorkspaceMismatch,
11662 "session belongs to another workspace",
11663 ));
11664 }
11665 if binding.generation != address.session.generation {
11666 return Err(failure(
11667 NodeFailureCode::StaleGeneration,
11668 &format!(
11669 "stale session generation: expected {}, received {}",
11670 binding.generation.0,
11671 address.session.generation.0,
11672 ),
11673 ));
11674 }
11675 let snapshot = self.handle.snapshot();
11676 let Some(current) = snapshot
11677 .sessions
11678 .iter()
11679 .find(|item| item.instance_id == address.session.instance_id)
11680 else {
11681 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11682 };
11683 if current.generation != address.session.generation {
11684 return Err(failure(
11685 NodeFailureCode::StaleGeneration,
11686 &format!(
11687 "stale session generation: expected {}, received {}",
11688 current.generation.0,
11689 address.session.generation.0,
11690 ),
11691 ));
11692 }
11693 Ok(current.agent_id.clone())
11694 }
11695
11696 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11697 self.dispatch_envelope(self.prepare_command(command))
11698 }
11699
11700 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11701 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11702 CommandEnvelope {
11703 id: CommandId(id),
11704 command,
11705 }
11706 }
11707
11708 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11709 let command_id = envelope.id;
11710 self.handle
11711 .dispatch(envelope)
11712 .map_err(|error| match error {
11713 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11714 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11715 })?;
11716 Ok(command_id)
11717 }
11718
11719 fn record_rejected_command(
11728 &self,
11729 command_id: CommandId,
11730 message: String,
11731 unsupported_transport: bool,
11732 ) {
11733 let now = Instant::now();
11734 let mut reasons = self.rejected_command_reasons.lock()
11735 .unwrap_or_else(|poisoned| poisoned.into_inner());
11736 reasons.retain(|_, reason| reason.expires_at > now);
11737 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11738 if let Some(&oldest) = reasons.keys().next() {
11739 reasons.remove(&oldest);
11740 }
11741 }
11742 reasons.insert(
11743 command_id,
11744 RejectedCommandReason {
11745 message,
11746 unsupported_transport,
11747 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11748 },
11749 );
11750 }
11751
11752 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11759 let now = Instant::now();
11760 let mut reasons = self.rejected_command_reasons.lock()
11761 .unwrap_or_else(|poisoned| poisoned.into_inner());
11762 match reasons.remove(&command_id) {
11763 Some(reason) if reason.expires_at > now => Some(reason),
11764 _ => None,
11765 }
11766 }
11767
11768 async fn dispatch_bounded(
11769 &self,
11770 command: ControlCommand,
11771 timeout_duration: Duration,
11772 ) -> Result<CommandId, NodeFailure> {
11773 let deadline = Instant::now() + timeout_duration;
11774 loop {
11775 match self.dispatch(command.clone()) {
11776 Ok(command_id) => return Ok(command_id),
11777 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11778 sleep(Duration::from_millis(2)).await;
11779 }
11780 Err(error) => return Err(error),
11781 }
11782 }
11783 }
11784
11785 fn internal_session_snapshot(
11786 &self,
11787 address: &SessionAddress,
11788 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11789 self.validate_address(address)?;
11790 self.handle
11791 .snapshot()
11792 .sessions
11793 .clone()
11794 .into_iter()
11795 .find(|session| {
11796 session.instance_id == address.session.instance_id
11797 && session.generation == address.session.generation
11798 })
11799 .ok_or_else(|| {
11800 failure(
11801 NodeFailureCode::UnknownSession,
11802 "session snapshot is unavailable",
11803 )
11804 })
11805 }
11806
11807 fn require_acp_session(
11815 &self,
11816 address: &SessionAddress,
11817 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11818 let snapshot = self.internal_session_snapshot(address)?;
11819 if snapshot.transport != TransportKind::Acp {
11820 return Err(failure(
11821 NodeFailureCode::UnsupportedTransport,
11822 "this verb requires a session spawned over the ACP transport",
11823 ));
11824 }
11825 Ok(snapshot)
11826 }
11827
11828 async fn dispatch_history_bounded(
11829 &self,
11830 address: &SessionAddress,
11831 command: ControlCommand,
11832 operation: HistoryOperation,
11833 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11834 self.validate_address(address)?;
11835 let started_after = self.current_sequence();
11836 let command_id = self
11837 .dispatch_bounded(
11838 command,
11839 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11840 )
11841 .await?;
11842 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11843 let mut scan_after = started_after;
11844 let mut accepted_after = None;
11845 loop {
11846 let events = self
11847 .history
11848 .lock()
11849 .unwrap_or_else(|poisoned| poisoned.into_inner())
11850 .events
11851 .iter()
11852 .filter(|envelope| envelope.sequence > scan_after)
11853 .cloned()
11854 .collect::<Vec<_>>();
11855 for envelope in events {
11856 scan_after = scan_after.max(envelope.sequence);
11857 let NodeEvent::Control {
11858 address: event_address,
11859 event,
11860 } = envelope.event
11861 else {
11862 continue;
11863 };
11864 if event_address != *address {
11865 continue;
11866 }
11867 if event.command_id == Some(command_id) {
11868 match &event.event {
11869 ControlEventKind::CommandRejected { .. } => {
11870 return Err(failure(
11871 NodeFailureCode::InvalidRequest,
11872 "history request was rejected",
11873 ));
11874 }
11875 ControlEventKind::HistoryRequested {
11876 operation: requested,
11877 ..
11878 } if requested == &operation => {
11879 accepted_after = Some(envelope.sequence);
11880 continue;
11881 }
11882 _ => {}
11883 }
11884 }
11885 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11886 match (&operation, &event.event) {
11887 (
11888 HistoryOperation::Discover { .. },
11889 ControlEventKind::HistoryDiscovered { .. },
11890 )
11891 | (
11892 HistoryOperation::Load { .. },
11893 ControlEventKind::HistoryLoaded { .. },
11894 ) => return self.internal_session_snapshot(address),
11895 (_, ControlEventKind::HistoryFailed { .. }) => {
11896 return Err(failure(
11897 NodeFailureCode::BackendOperationFailed,
11898 "history operation failed",
11899 ));
11900 }
11901 _ => {}
11902 }
11903 }
11904 }
11905 if Instant::now() >= deadline {
11906 return Err(failure(
11907 NodeFailureCode::BackendBusy,
11908 if accepted_after.is_some() {
11909 "history operation did not settle before the bounded deadline"
11910 } else {
11911 "history operation was not accepted before the bounded deadline"
11912 },
11913 ));
11914 }
11915 sleep(Duration::from_millis(2)).await;
11916 }
11917 }
11918
11919 async fn discover_history(
11920 &self,
11921 address: &SessionAddress,
11922 limit: u16,
11923 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11924 let query = HistoryQuery {
11925 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11926 limit,
11927 };
11928 query
11929 .validate()
11930 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11931 let snapshot = self
11932 .dispatch_history_bounded(
11933 address,
11934 ControlCommand::DiscoverHistory {
11935 instance_id: address.session.instance_id,
11936 query: query.clone(),
11937 },
11938 HistoryOperation::Discover { query },
11939 )
11940 .await?;
11941 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11942 return Err(failure(
11943 NodeFailureCode::BackendOperationFailed,
11944 "history discovery settled into an invalid state",
11945 ));
11946 }
11947 Ok(snapshot.history.candidates)
11948 }
11949
11950 async fn load_history(
11951 &self,
11952 address: &SessionAddress,
11953 candidate_id: String,
11954 ) -> Result<HistorySessionRecord, NodeFailure> {
11955 validate_candidate_id(&candidate_id)
11956 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11957 let snapshot = self
11958 .dispatch_history_bounded(
11959 address,
11960 ControlCommand::LoadHistory {
11961 instance_id: address.session.instance_id,
11962 candidate_id: candidate_id.clone(),
11963 },
11964 HistoryOperation::Load {
11965 candidate_id: candidate_id.clone(),
11966 },
11967 )
11968 .await?;
11969 if snapshot.history.pending.is_some()
11970 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11971 {
11972 return Err(failure(
11973 NodeFailureCode::BackendOperationFailed,
11974 "history load settled into an invalid state",
11975 ));
11976 }
11977 snapshot.history.loaded.ok_or_else(|| {
11978 failure(
11979 NodeFailureCode::BackendOperationFailed,
11980 "history load produced no bounded session",
11981 )
11982 })
11983 }
11984
11985 async fn export_context_pack_for_session_record(
11992 &self,
11993 record_id: &SessionRecordId,
11994 session: &SessionAddress,
11995 allow_clean_detachment: bool,
11996 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11997 let outcome = self
12003 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12004 .await;
12005 if let Err(failure) = &outcome {
12006 tracing::warn!(
12007 record = %record_id,
12008 code = ?failure.code,
12009 reason = %failure.message,
12010 "session-record context-pack export refused",
12011 );
12012 }
12013 outcome
12014 }
12015
12016 async fn export_context_pack_for_session_record_inner(
12017 &self,
12018 record_id: &SessionRecordId,
12019 session: &SessionAddress,
12020 allow_clean_detachment: bool,
12021 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12022 let step = |name: &'static str| move |failure: NodeFailure| {
12027 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12028 failure
12029 };
12030 let record = self.record(record_id).map_err(step("record-lookup"))?;
12031 let identity = match record.provider_session.clone() {
12047 Some(identity) => {
12048 if identity.key != ProviderSessionKey::SessionId {
12049 return Err(step("provider-identity-key")(failure(
12050 NodeFailureCode::BackendOperationFailed,
12051 "managed session provider identity cannot bind history exactly",
12052 )));
12053 }
12054 Some(identity)
12055 }
12056 None if record.state == ManagedSessionState::IdentityPending => {
12057 return Err(step("provider-session-identity")(failure(
12058 NodeFailureCode::BackendOperationFailed,
12059 "managed session has not yet observed its provider session identity",
12060 )));
12061 }
12062 None => None,
12063 };
12064 self.revalidate_session_record_context_export(
12065 &record,
12066 identity.as_ref(),
12067 session,
12068 allow_clean_detachment,
12069 ).map_err(step("revalidate-before-discovery"))?;
12070 let candidates = self
12071 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12072 .await
12073 .map_err(step("discover-history"))?;
12074 let candidate_id = select_history_candidate(
12079 &candidates,
12080 identity.as_ref().map(|identity| identity.id.as_str()),
12081 ).map_err(step("unique-candidate"))?;
12082 let loaded = self.load_history(session, candidate_id.clone()).await
12083 .map_err(step("load-history"))?;
12084 if let Some(identity) = identity.as_ref() {
12085 if loaded.session_id != identity.id {
12086 return Err(failure(
12087 NodeFailureCode::SessionRecordConflict,
12088 "loaded history identity does not match the managed session record",
12089 ));
12090 }
12091 }
12092 self.revalidate_session_record_context_export(
12093 &record,
12094 identity.as_ref(),
12095 session,
12096 allow_clean_detachment,
12097 )?;
12098 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12099 .map_err(step("revalidate-loaded-history"))?;
12100 let pack = self.materialize_context_pack(session).await
12101 .map_err(step("materialize-context-pack"))?;
12102 self.commit_context_pack_for_session_record(
12103 &record,
12104 identity.as_ref(),
12105 session,
12106 &candidate_id,
12107 &loaded,
12108 pack,
12109 allow_clean_detachment,
12110 )
12111 }
12112
12113 async fn export_context_pack(
12114 &self,
12115 address: &SessionAddress,
12116 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12117 let pack = self.materialize_context_pack(address).await?;
12118 self.commit_context_pack(pack)
12119 }
12120
12121 async fn materialize_context_pack(
12122 &self,
12123 address: &SessionAddress,
12124 ) -> Result<NodeContextPack, NodeFailure> {
12125 let snapshot = self.internal_session_snapshot(address)?;
12126 if !context_pack_source_status_is_usable(&snapshot.status) {
12127 return Err(failure(
12128 NodeFailureCode::InvalidRequest,
12129 "context export requires a running or successfully exited source session",
12130 ));
12131 }
12132 if snapshot.history.pending.is_some()
12133 || snapshot.history.loaded_candidate_id.is_none()
12134 {
12135 return Err(failure(
12136 NodeFailureCode::InvalidRequest,
12137 "context export requires settled loaded history",
12138 ));
12139 }
12140 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12141 failure(
12142 NodeFailureCode::InvalidRequest,
12143 "context export requires settled loaded history",
12144 )
12145 })?;
12146 let source_provider = snapshot.agent_id.clone();
12147 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12148 let repository = self
12149 .context_pack_repository(&address.workspace_id)
12150 .await?;
12151 let current = self.internal_session_snapshot(address)?;
12152 if !context_pack_source_status_is_usable(¤t.status)
12153 || current.agent_id != source_provider
12154 || current.history.pending.is_some()
12155 || current.history.loaded_candidate_id != loaded_candidate_id
12156 || current.history.loaded.as_ref() != Some(&loaded)
12157 {
12158 return Err(failure(
12159 NodeFailureCode::ContextPackMaterializationFailed,
12160 "context source changed during bounded repository capture",
12161 ));
12162 }
12163 NodeContextPack::export_with_repository(
12164 ContextPackLineageReceipt {
12165 source_node_id: self.node_id.clone(),
12166 source_session: address.clone(),
12167 source_provider,
12168 },
12169 &loaded,
12170 Some(repository),
12171 )
12172 .map_err(|_| {
12173 failure(
12174 NodeFailureCode::ContextPackMaterializationFailed,
12175 "bounded context pack export failed",
12176 )
12177 })
12178 }
12179
12180 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12186 let mut store = self
12187 .context_pack_store
12188 .lock()
12189 .unwrap_or_else(|poisoned| poisoned.into_inner());
12190 let Some(store) = store.as_mut() else {
12191 return Ok(());
12192 };
12193 store.commit(pack).map_err(|_| {
12194 failure(
12195 NodeFailureCode::ContextPackMaterializationFailed,
12196 "durable context pack commit failed",
12197 )
12198 })
12199 }
12200
12201 fn commit_context_pack(
12202 &self,
12203 pack: NodeContextPack,
12204 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12205 self.commit_context_pack_durably(&pack)?;
12206 self.context_catalog
12207 .write()
12208 .unwrap_or_else(|poisoned| poisoned.into_inner())
12209 .insert(pack)
12210 .map_err(|_| {
12211 failure(
12212 NodeFailureCode::ContextPackMaterializationFailed,
12213 "context pack catalog is full",
12214 )
12215 })
12216 }
12217
12218 fn commit_context_pack_for_session_record(
12219 &self,
12220 expected: &ManagedSessionRecord,
12221 identity: Option<&ProviderSessionIdentity>,
12222 session: &SessionAddress,
12223 candidate_id: &str,
12224 loaded: &HistorySessionRecord,
12225 pack: NodeContextPack,
12226 allow_clean_detachment: bool,
12227 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12228 let current_root = self.workspace_root(&expected.workspace_id)?;
12229 let _transaction = self
12230 .state_transaction
12231 .lock()
12232 .unwrap_or_else(|poisoned| poisoned.into_inner());
12233 let records = self
12234 .session_records
12235 .lock()
12236 .unwrap_or_else(|poisoned| poisoned.into_inner());
12237 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12238 failure(
12239 NodeFailureCode::SessionRecordConflict,
12240 "managed session record disappeared during context export",
12241 )
12242 })?;
12243 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12244 return Err(failure(
12245 NodeFailureCode::SessionRecordConflict,
12246 "managed session record changed during context export",
12247 ));
12248 }
12249 let bindings = self
12250 .session_bindings
12251 .lock()
12252 .unwrap_or_else(|poisoned| poisoned.into_inner());
12253 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12254 failure(
12255 NodeFailureCode::SessionRecordConflict,
12256 "managed session runtime binding disappeared during context export",
12257 )
12258 })?;
12259 let runtime = self
12260 .handle
12261 .snapshot()
12262 .sessions
12263 .iter()
12264 .find(|snapshot| {
12265 snapshot.instance_id == session.session.instance_id
12266 && snapshot.generation == session.session.generation
12267 })
12268 .cloned()
12269 .ok_or_else(|| {
12270 failure(
12271 NodeFailureCode::SessionRecordConflict,
12272 "managed session runtime disappeared during context export",
12273 )
12274 })?;
12275 let receipt = pack.receipt();
12276 if !session_record_context_export_binding_is_exact(
12277 expected,
12278 current,
12279 identity,
12280 session,
12281 binding,
12282 &runtime.agent_id,
12283 ¤t_root,
12284 allow_clean_detachment,
12285 ) || !session_record_context_export_source_is_usable(
12286 current,
12287 &runtime.status,
12288 allow_clean_detachment,
12289 ) || runtime.history.pending.is_some()
12290 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12291 || runtime.history.loaded.as_ref() != Some(loaded)
12292 || receipt.lineage.source_node_id != self.node_id
12293 || receipt.lineage.source_session != *session
12294 || receipt.lineage.source_provider != current.provider
12295 || receipt.source_message_count != loaded.message_count
12296 {
12297 return Err(failure(
12298 NodeFailureCode::SessionRecordConflict,
12299 "managed session source changed before context catalog commit",
12300 ));
12301 }
12302 self.commit_context_pack_durably(&pack)?;
12303 self.context_catalog
12304 .write()
12305 .unwrap_or_else(|poisoned| poisoned.into_inner())
12306 .insert(pack)
12307 .map_err(|_| {
12308 failure(
12309 NodeFailureCode::ContextPackMaterializationFailed,
12310 "context pack catalog is full",
12311 )
12312 })
12313 }
12314
12315 async fn context_pack_repository(
12316 &self,
12317 workspace_id: &WorkspaceId,
12318 ) -> Result<ContextPackRepository, NodeFailure> {
12319 let first = self
12320 .context_pack_repository_observation(workspace_id)
12321 .await?;
12322 let second = self
12323 .context_pack_repository_observation(workspace_id)
12324 .await?;
12325 stable_context_pack_repository(first, second)
12326 }
12327
12328 async fn context_pack_repository_observation(
12329 &self,
12330 workspace_id: &WorkspaceId,
12331 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12332 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12333 let head = if git.is_repository {
12334 let root = self.workspace_root(workspace_id)?;
12335 match resolve_base_commit_with_timeout(
12336 &root,
12337 "HEAD",
12338 GIT_COMMAND_TIMEOUT_MS,
12339 )
12340 .await
12341 {
12342 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12343 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12344 ContextPackRepositoryHead::Unborn
12345 }
12346 Err(_) => {
12347 return Err(failure(
12348 NodeFailureCode::ContextPackMaterializationFailed,
12349 "context pack HEAD observation failed",
12350 ));
12351 }
12352 }
12353 } else {
12354 ContextPackRepositoryHead::NotRepository
12355 };
12356 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12357 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12358 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12359 failure(
12360 NodeFailureCode::InvalidRepositoryPath,
12361 "context pack selected file path is invalid",
12362 )
12363 })?;
12364 match self.read_workspace_file(workspace_id.clone(), path).await {
12365 Ok(WorkspaceFileRead {
12366 content: WorkspaceFileContent::Utf8 { text, byte_len },
12367 ..
12368 }) => files.push(ContextPackRepositoryFileSource::utf8(
12369 selected_path,
12370 text,
12371 byte_len,
12372 )),
12373 Ok(WorkspaceFileRead {
12374 content: WorkspaceFileContent::NonUtf8 { byte_len },
12375 ..
12376 }) => files.push(ContextPackRepositoryFileSource::skipped(
12377 selected_path,
12378 ContextPackSelectedFileSkipReason::NonUtf8,
12379 Some(byte_len),
12380 )),
12381 Ok(WorkspaceFileRead {
12382 content: WorkspaceFileContent::TooLarge { .. },
12383 ..
12384 }) => files.push(ContextPackRepositoryFileSource::skipped(
12385 selected_path,
12386 ContextPackSelectedFileSkipReason::TooLarge,
12387 None,
12388 )),
12389 Err(error) => {
12390 let reason = if matches!(
12391 error.code,
12392 NodeFailureCode::InvalidRepositoryPath
12393 | NodeFailureCode::RepositoryFileNotRegular
12394 | NodeFailureCode::RepositoryPathUnsafe
12395 ) {
12396 ContextPackSelectedFileSkipReason::Unsafe
12397 } else {
12398 ContextPackSelectedFileSkipReason::Unavailable
12399 };
12400 files.push(ContextPackRepositoryFileSource::skipped(
12401 selected_path,
12402 reason,
12403 None,
12404 ));
12405 }
12406 }
12407 }
12408 Ok(ContextPackRepositoryObservation { head, git, files })
12409 }
12410
12411 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12412 let referenced_by_binding = self
12413 .session_bindings
12414 .lock()
12415 .unwrap_or_else(|poisoned| poisoned.into_inner())
12416 .values()
12417 .any(|binding| {
12418 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12419 });
12420 let referenced_by_record = self
12421 .session_records
12422 .lock()
12423 .unwrap_or_else(|poisoned| poisoned.into_inner())
12424 .records
12425 .values()
12426 .any(|record| record.context_id.as_ref() == Some(context_id));
12427 let referenced_by_materialization = self
12428 .materializations
12429 .lock()
12430 .unwrap_or_else(|poisoned| poisoned.into_inner())
12431 .values()
12432 .any(|ownership| {
12433 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12434 });
12435 if referenced_by_binding
12436 || referenced_by_record
12437 || referenced_by_materialization
12438 {
12439 return Err(failure(
12440 NodeFailureCode::ContextPackBusy,
12441 "context pack is still referenced",
12442 ));
12443 }
12444 let mut catalog = self.context_catalog
12445 .write()
12446 .unwrap_or_else(|poisoned| poisoned.into_inner());
12447 if catalog.get(context_id).is_none() {
12448 return Err(failure(
12449 NodeFailureCode::UnknownContextPack,
12450 "context pack is unavailable",
12451 ));
12452 }
12453 self.spawn_idempotency
12454 .lock()
12455 .unwrap_or_else(|poisoned| poisoned.into_inner())
12456 .entries
12457 .retain(|_, entry| !entry.value.references_context(context_id));
12458 let removed = catalog.remove(context_id);
12459 debug_assert!(removed.is_some(), "validated context pack remains present");
12460 Ok(())
12461 }
12462
12463 async fn dispatch_input_bounded(
12464 &self,
12465 address: &SessionAddress,
12466 action: InputAction,
12467 ) -> Result<(), NodeFailure> {
12468 let started_after = self.current_sequence();
12469 self.pty_input_marks
12470 .lock()
12471 .unwrap_or_else(|poisoned| poisoned.into_inner())
12472 .insert(address.session.instance_id, (Instant::now(), false));
12473 self.pty_input_wake.notify_one();
12474 let command_id = self
12475 .dispatch_bounded(
12476 ControlCommand::SendInput {
12477 instance_id: address.session.instance_id,
12478 action,
12479 },
12480 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12481 )
12482 .await?;
12483 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12487 let mut scan_after = started_after;
12488 let mut accepted_after = None;
12489 loop {
12490 let events = self
12491 .history
12492 .lock()
12493 .unwrap_or_else(|poisoned| poisoned.into_inner())
12494 .events
12495 .iter()
12496 .filter(|envelope| envelope.sequence > scan_after)
12497 .cloned()
12498 .collect::<Vec<_>>();
12499 for envelope in events {
12500 scan_after = scan_after.max(envelope.sequence);
12501 let NodeEvent::Control {
12502 address: event_address,
12503 event,
12504 } = envelope.event
12505 else {
12506 continue;
12507 };
12508 if event_address != *address {
12509 continue;
12510 }
12511 if event.command_id == Some(command_id) {
12512 match &event.event {
12513 ControlEventKind::CommandRejected { message } => {
12514 return Err(failure(NodeFailureCode::InvalidRequest, message));
12515 }
12516 ControlEventKind::InputRequested { .. } => {
12517 accepted_after = Some(envelope.sequence);
12518 continue;
12519 }
12520 _ => {}
12521 }
12522 }
12523 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12524 match &event.event {
12525 ControlEventKind::InputCompleted { .. } => return Ok(()),
12526 ControlEventKind::InputFailed { message, .. } => {
12527 return Err(failure(
12528 NodeFailureCode::BackendOperationFailed,
12529 message,
12530 ));
12531 }
12532 _ => {}
12533 }
12534 }
12535 }
12536 if Instant::now() >= deadline {
12537 return Err(failure(
12538 NodeFailureCode::BackendBusy,
12539 if accepted_after.is_some() {
12540 "input was accepted but did not settle before the bounded deadline"
12541 } else {
12542 "input was not accepted before the bounded deadline"
12543 },
12544 ));
12545 }
12546 sleep(Duration::from_millis(2)).await;
12547 }
12548 }
12549
12550 async fn dispatch_resize_bounded(
12551 &self,
12552 address: &SessionAddress,
12553 size: gate4agent_types::TerminalSize,
12554 ) -> Result<(), NodeFailure> {
12555 let started_after = self.current_sequence();
12556 let command_id = self
12557 .dispatch_bounded(
12558 ControlCommand::Resize {
12559 instance_id: address.session.instance_id,
12560 size,
12561 },
12562 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12563 )
12564 .await?;
12565 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12566 let mut scan_after = started_after;
12567 let mut accepted_after = None;
12568 loop {
12569 let events = self
12570 .history
12571 .lock()
12572 .unwrap_or_else(|poisoned| poisoned.into_inner())
12573 .events
12574 .iter()
12575 .filter(|envelope| envelope.sequence > scan_after)
12576 .cloned()
12577 .collect::<Vec<_>>();
12578 for envelope in events {
12579 scan_after = scan_after.max(envelope.sequence);
12580 let NodeEvent::Control {
12581 address: event_address,
12582 event,
12583 } = envelope.event
12584 else {
12585 continue;
12586 };
12587 if event_address != *address {
12588 continue;
12589 }
12590 if event.command_id == Some(command_id) {
12591 match &event.event {
12592 ControlEventKind::CommandRejected { message } => {
12593 return Err(failure(NodeFailureCode::InvalidRequest, message));
12594 }
12595 ControlEventKind::ResizeRequested {
12596 size: requested_size,
12597 ..
12598 } if *requested_size == size => {
12599 accepted_after = Some(envelope.sequence);
12600 continue;
12601 }
12602 _ => {}
12603 }
12604 }
12605 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12606 match &event.event {
12607 ControlEventKind::Resized { size: settled_size }
12608 if *settled_size == size =>
12609 {
12610 return Ok(());
12611 }
12612 ControlEventKind::ResizeFailed { message } => {
12613 return Err(failure(
12614 NodeFailureCode::BackendOperationFailed,
12615 message,
12616 ));
12617 }
12618 _ => {}
12619 }
12620 }
12621 }
12622 if Instant::now() >= deadline {
12623 return Err(failure(
12624 NodeFailureCode::BackendBusy,
12625 if accepted_after.is_some() {
12626 "resize was accepted but did not settle before the bounded deadline"
12627 } else {
12628 "resize was not accepted before the bounded deadline"
12629 },
12630 ));
12631 }
12632 sleep(Duration::from_millis(2)).await;
12633 }
12634 }
12635
12636 async fn spawn_session(
12637 &self,
12638 workspace_id: WorkspaceId,
12639 provider: AgentId,
12640 mode: SessionMode,
12641 terminal_size: gate4agent_types::TerminalSize,
12642 initial_prompt: Option<String>,
12643 ) -> Result<SessionAddress, NodeFailure> {
12644 if self.managed_worktrees.lock()
12645 .unwrap_or_else(|poisoned| poisoned.into_inner())
12646 .lease_for_workspace(&workspace_id)
12647 .is_some()
12648 {
12649 return Err(failure(
12650 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12651 "managed worktree targets may only be spawned through their lease request",
12652 ));
12653 }
12654 self
12655 .spawn_session_with_deadline(
12656 workspace_id,
12657 provider,
12658 mode,
12659 terminal_size,
12660 initial_prompt,
12661 None,
12662 None,
12663 None,
12664 None,
12665 None,
12666 SpawnRecordPolicy::ProviderIdentityOnly,
12667 None,
12668 &[],
12669 None,
12670 ApprovalLevel::default(),
12676 None,
12677 None,
12678 )
12679 .await
12680 .map(|(session, _runtime_policy)| session)
12681 }
12682
12683 async fn spawn_session_with_deadline(
12684 &self,
12685 workspace_id: WorkspaceId,
12686 provider: AgentId,
12687 mode: SessionMode,
12688 terminal_size: gate4agent_types::TerminalSize,
12689 initial_prompt: Option<String>,
12690 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12691 bundle: Option<ResolvedBundleReceipt>,
12692 context: Option<ResolvedContextPackReceipt>,
12693 managed_authority: Option<ManagedWorktreeLeaseId>,
12694 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12695 record_policy: SpawnRecordPolicy,
12696 deadline: Option<Instant>,
12697 required_capabilities: &[ProviderRuntimeCapability],
12698 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12699 approval_level: ApprovalLevel,
12700 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12701 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12707 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12708 let reserved_lease = self.managed_worktrees.lock()
12709 .unwrap_or_else(|poisoned| poisoned.into_inner())
12710 .lease_for_workspace(&workspace_id);
12711 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12712 (Some(expected), Some(provided)) if expected == provided => {}
12713 (None, None) => {}
12714 _ => {
12715 return Err(failure(
12716 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12717 "spawn lacks exact managed worktree lease authority",
12718 ));
12719 }
12720 }
12721 self.ensure_workspace_environment_profile_compatible(
12725 &workspace_id,
12726 environment_profile.as_ref(),
12727 )?;
12728 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12729 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12730 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12731 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12732 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12733 };
12734 let transport = match mode {
12740 SessionMode::Pty => TransportKind::Pty,
12741 SessionMode::Inline => TransportKind::Pipe,
12742 SessionMode::Acp => TransportKind::Acp,
12743 };
12744 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12745 let acp_transport = self.provider_supports_acp_transport(&provider);
12746 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12747 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12748 NodeFailureCode::UnsupportedSpawnCapability,
12749 "pre-admitted provider runtime no longer satisfies spawn requirements",
12750 ))?;
12751 runtime_policy
12752 } else if let Some(deadline) = deadline {
12753 let remaining = spawn_deadline_remaining(deadline)?;
12754 timeout(
12755 remaining,
12756 self.admit_provider_runtime(&provider, runtime_requirement),
12757 )
12758 .await
12759 .map_err(|_| {
12760 failure(
12761 NodeFailureCode::SpawnDeadlineExceeded,
12762 "provider admission exceeded the spawn deadline",
12763 )
12764 })??
12765 } else {
12766 self.admit_provider_runtime(&provider, runtime_requirement).await?
12767 };
12768 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12769 let runtime_policy =
12781 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12782 if required_capabilities
12783 .iter()
12784 .any(|capability| !runtime_policy.admits(*capability))
12785 {
12786 return Err(failure(
12787 NodeFailureCode::UnsupportedSpawnCapability,
12788 "provider runtime does not admit a required spawn capability",
12789 ));
12790 }
12791 if let Some(deadline) = deadline {
12792 spawn_deadline_remaining(deadline)?;
12793 }
12794 self.ensure_binding_capacity()?;
12795 let mut working_directory = self.workspace_root(&workspace_id)?;
12796 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12797 let session = SessionKey {
12798 instance_id,
12799 generation: SessionGeneration(1),
12800 };
12801 let address = SessionAddress {
12802 workspace_id: workspace_id.clone(),
12803 session,
12804 };
12805 tracing::info!(
12813 node_id = %self.node_id,
12814 workspace_id = %workspace_id,
12815 provider = %provider,
12816 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12817 session = ?address.session,
12818 "session spawn request accepted",
12819 );
12820 let mut prepared_spawn = SpawnPreparer::prepare(
12821 self,
12822 SpawnPrepareRequest {
12823 instance_id,
12824 address: &address,
12825 provider: &provider,
12826 mode,
12827 environment_profile: environment_profile.as_ref(),
12828 bundle: bundle.as_ref(),
12829 context: context.as_ref(),
12830 managed_authority: managed_authority.as_ref(),
12831 harness_mcp,
12832 approval_level,
12833 network_allowlist: network_allowlist.as_ref(),
12834 browser_profile_id,
12835 },
12836 )?;
12837 let materialization_id = prepared_spawn.materialization_id.take();
12838 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12839 let record_id = self.bind_spawn_session_with_materialization(
12840 &address,
12841 provider.clone(),
12842 mode,
12843 runtime_policy,
12844 record_policy,
12845 environment_profile,
12846 bundle,
12847 context,
12848 materialization_id,
12849 )?;
12850 prepared_spawn.retain_session_guards();
12851 let session_provider = provider.clone();
12855 let agent_id = provider;
12856 let register_command_id = match self.dispatch_bounded(
12857 ControlCommand::Register {
12858 instance_id,
12859 agent_id,
12860 transport,
12861 },
12862 dispatch_timeout,
12863 )
12864 .await {
12865 Ok(command_id) => command_id,
12866 Err(error) => {
12867 if let Some(binding) = self.remove_binding(&address) {
12868 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12869 }
12870 if let Some(record_id) = record_id.as_ref() {
12871 self.discard_record(record_id)?;
12872 }
12873 return Err(spawn_dispatch_error(error, deadline));
12874 }
12875 };
12876 let start_timeout = match spawn_dispatch_timeout(deadline) {
12877 Ok(timeout) => timeout,
12878 Err(error) => {
12879 let recovery = self
12880 .rollback_spawn(
12881 &address,
12882 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12883 )
12884 .await;
12885 return match recovery {
12886 Ok(()) => {
12887 if let Some(record_id) = record_id.as_ref() {
12888 self.discard_record(record_id)?;
12889 }
12890 Err(error)
12891 }
12892 Err(recovery_error) => {
12893 if let Some(record_id) = record_id.as_ref() {
12894 self.mark_record_error(
12895 record_id,
12896 "spawn deadline elapsed and registration recovery failed",
12897 )?;
12898 }
12899 Err(recovery_error)
12900 }
12901 };
12902 }
12903 };
12904 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12905 if let Err(recovery_error) = self
12906 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12907 .await
12908 {
12909 return Err(recovery_error);
12910 }
12911 if let Some(record_id) = record_id.as_ref() {
12912 self.discard_record(record_id)?;
12913 }
12914 return Err(start_error);
12915 }
12916 if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
12918 working_directory = dir.to_string_lossy().into_owned();
12919 }
12920 let start_result = self
12921 .dispatch_bounded(
12922 ControlCommand::Start {
12923 instance_id,
12924 runtime_policy,
12925 request: StartRequest {
12926 working_directory,
12927 terminal_size,
12928 initial_prompt,
12929 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12930 approval_level,
12931 },
12932 },
12933 start_timeout,
12934 )
12935 .await;
12936 let start_command_id = match start_result {
12937 Ok(command_id) => command_id,
12938 Err(start_error) => {
12939 let start_error = spawn_dispatch_error(start_error, deadline);
12940 let recovery = self
12941 .rollback_spawn(
12942 &address,
12943 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12944 )
12945 .await;
12946 return match recovery {
12947 Ok(()) => {
12948 if let Some(record_id) = record_id.as_ref() {
12949 self.discard_record(record_id)?;
12950 }
12951 Err(start_error)
12952 }
12953 Err(recovery_error) => {
12954 let diagnostic = format!(
12955 "start failed and registration recovery failed: {}",
12956 recovery_error.message,
12957 );
12958 if let Some(record_id) = record_id.as_ref() {
12959 self.mark_record_error(record_id, &diagnostic)?;
12960 }
12961 Err(failure(recovery_error.code, &diagnostic))
12962 }
12963 };
12964 }
12965 };
12966
12967 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12968 loop {
12969 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12978 .or_else(|| self.take_rejected_command_reason(start_command_id))
12979 {
12980 self.rollback_spawn(
12981 &address,
12982 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12983 ).await.map_err(|error| {
12984 failure(
12985 error.code,
12986 &format!(
12987 "spawn was rejected ({}) and registration recovery failed: {}",
12988 reason.message, error.message
12989 ),
12990 )
12991 })?;
12992 if let Some(record_id) = record_id.as_ref() {
12993 self.discard_record(record_id)?;
12994 }
12995 let code = if reason.unsupported_transport {
12996 NodeFailureCode::UnsupportedTransport
12997 } else {
12998 NodeFailureCode::BackendOperationFailed
12999 };
13000 return Err(failure(code, &reason.message));
13001 }
13002 if let Some(current) = self
13003 .handle
13004 .snapshot()
13005 .sessions
13006 .iter()
13007 .find(|current| current.instance_id == instance_id)
13008 {
13009 if current.generation != session.generation {
13010 let divergence = failure(
13011 NodeFailureCode::StaleGeneration,
13012 "spawn generation diverged",
13013 );
13014 return match self
13015 .rollback_spawn(
13016 &address,
13017 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13018 )
13019 .await
13020 {
13021 Ok(()) => {
13022 if let Some(record_id) = record_id.as_ref() {
13023 self.discard_record(record_id)?;
13024 }
13025 Err(divergence)
13026 }
13027 Err(recovery_error) => {
13028 if let Some(record_id) = record_id.as_ref() {
13029 self.mark_record_error(
13030 record_id,
13031 "spawn generation diverged and recovery failed",
13032 )?;
13033 }
13034 Err(recovery_error)
13035 }
13036 };
13037 }
13038 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13039 prepared_spawn.retain_launch_guards();
13040 return Ok((address, runtime_policy));
13041 }
13042 }
13043 if Instant::now() >= commit_deadline {
13044 self.rollback_spawn(
13045 &address,
13046 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13047 ).await.map_err(|error| {
13048 failure(
13049 error.code,
13050 &format!(
13051 "start did not commit and registration recovery failed: {}",
13052 error.message
13053 ),
13054 )
13055 })?;
13056 if let Some(record_id) = record_id.as_ref() {
13057 self.discard_record(record_id)?;
13058 }
13059 return Err(failure(
13060 if deadline.is_some() {
13061 NodeFailureCode::SpawnDeadlineExceeded
13062 } else {
13063 NodeFailureCode::BackendBusy
13064 },
13065 "start did not commit before the bounded deadline; registration was removed",
13066 ));
13067 }
13068 sleep(Duration::from_millis(2)).await;
13069 }
13070 }
13071
13072 fn effective_spawn_runtime_policy(
13073 &self,
13074 runtime_policy: ProviderRuntimePolicy,
13075 ) -> ProviderRuntimePolicy {
13076 #[cfg(feature = "fixture")]
13077 let runtime_policy = if self.fixture_semantic_hook_policy {
13078 ProviderRuntimePolicy::new(true, true, true, true, false, true)
13079 .expect("monitoring Hook fixture policy is internally valid")
13080 } else {
13081 runtime_policy
13082 };
13083 runtime_policy
13084 }
13085
13086 fn verify_harness_mcp_before_start(
13087 &self,
13088 prepared: Option<&PreparedHarnessMcpSpawn>,
13089 ) -> Result<(), NodeFailure> {
13090 prepared
13091 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13092 .transpose()
13093 .map(|_| ())
13094 .map_err(harness_mcp_failure)
13095 }
13096
13097 async fn rollback_spawn(
13098 &self,
13099 address: &SessionAddress,
13100 commit_timeout: Duration,
13101 ) -> Result<(), NodeFailure> {
13102 self.dispatch_bounded(
13103 ControlCommand::Remove {
13104 instance_id: address.session.instance_id,
13105 },
13106 commit_timeout,
13107 )
13108 .await?;
13109 self.wait_until_removed(address, commit_timeout).await?;
13110 if let Some(binding) = self.remove_binding(address) {
13111 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13112 }
13113 Ok(())
13114 }
13115
13116 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13117 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13118 self.dispatch_bounded(
13119 ControlCommand::Remove {
13120 instance_id: address.session.instance_id,
13121 },
13122 commit_timeout,
13123 )
13124 .await?;
13125 self.wait_until_removed(address, commit_timeout).await?;
13126 let removed = self.remove_binding(address);
13127 if let Some(binding) = removed.as_ref() {
13128 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13129 }
13130 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13131 let snapshot = self.managed_worktrees.lock()
13132 .unwrap_or_else(|poisoned| poisoned.into_inner())
13133 .release_session(
13134 &lease_id,
13135 self.incarnation_id,
13136 address.session.instance_id,
13137 address.session.generation,
13138 unix_time_ms(),
13139 );
13140 if self.persist_state().is_err() {
13141 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13142 }
13143 if let Some(snapshot) = snapshot {
13144 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13145 }
13146 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13147 }
13148 Ok(())
13149 }
13150
13151 async fn wait_until_removed(
13197 &self,
13198 address: &SessionAddress,
13199 commit_timeout: Duration,
13200 ) -> Result<(), NodeFailure> {
13201 let deadline = Instant::now() + commit_timeout;
13202 let mut next_dispatch = Instant::now();
13203 loop {
13204 let snapshot = self.handle.snapshot();
13205 let status = snapshot
13206 .sessions
13207 .iter()
13208 .find(|session| session.instance_id == address.session.instance_id)
13209 .map(|session| session.status.clone());
13210 let Some(status) = status else {
13211 return Ok(());
13212 };
13213 let now = Instant::now();
13214 if now >= deadline {
13215 return Err(failure(
13216 NodeFailureCode::BackendBusy,
13217 "remove did not commit before the bounded deadline; session binding was retained",
13218 ));
13219 }
13220 if status.allows_remove() && now >= next_dispatch {
13221 let _ = self.dispatch(ControlCommand::Remove {
13222 instance_id: address.session.instance_id,
13223 });
13224 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13225 }
13226 sleep(Duration::from_millis(2)).await;
13227 }
13228 }
13229
13230 fn resync(&self, after_sequence: u64) -> NodeResponse {
13231 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13232 let event_sequence = history.last_sequence;
13233 let oldest_available_sequence = history.replay_floor_sequence;
13234 let events = history
13235 .events
13236 .iter()
13237 .filter(|event| event.sequence > after_sequence)
13238 .cloned()
13239 .collect();
13240 drop(history);
13241 NodeResponse::Resync {
13242 event_sequence,
13243 oldest_available_sequence,
13244 snapshot: self.snapshot(),
13245 events,
13246 }
13247 }
13248}
13249
13250fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13255 match kind {
13256 AgentStreamChunkKindV1::Text { .. } => "text",
13257 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13258 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13259 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13260 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13261 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13262 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13263 }
13264}
13265
13266async fn accept_connections(
13280 endpoint: &str,
13281 call_home: Option<std::net::SocketAddr>,
13282 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13283 shared: Arc<NodeShared>,
13284) -> Result<(), NodeServerError> {
13285 let mut connections = JoinSet::new();
13286 let result = match call_home {
13287 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13288 Some(relay) => {
13289 let listener =
13290 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13291 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13292 tokio::pin!(listener);
13293 tokio::pin!(dialler);
13294 tokio::select! {
13295 result = &mut listener => result,
13296 result = &mut dialler => result,
13297 }
13298 }
13299 };
13300 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13301 while !connections.is_empty() {
13302 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13303 if remaining.is_zero()
13304 || timeout(remaining, connections.join_next()).await.is_err()
13305 {
13306 break;
13307 }
13308 }
13309 connections.abort_all();
13310 while connections.join_next().await.is_some() {}
13311 result
13312}
13313
13314const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13318
13319async fn call_home_loop(
13333 relay: std::net::SocketAddr,
13334 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13335 shared: Arc<NodeShared>,
13336) -> Result<(), NodeServerError> {
13337 let mut failures = 0_usize;
13338 loop {
13339 if shared.shutdown.load(Ordering::Acquire) {
13340 return Ok(());
13341 }
13342 let permit = tokio::select! {
13343 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13344 permit.map_err(|_| NodeServerError::PreauthClosed)?
13345 }
13346 _ = shared.shutdown_notify.notified() => return Ok(()),
13347 };
13348 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13349 Ok(()) => {
13350 failures = 0;
13354 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13355 }
13356 Err(error) => {
13357 failures = failures.saturating_add(1);
13358 tracing::warn!(
13359 node_id = %shared.node_id,
13360 %relay,
13361 cause = %error,
13362 attempt = failures,
13363 "call-home connection failed",
13364 );
13365 let backoff = call_home_backoff(failures);
13366 tokio::select! {
13367 _ = sleep(backoff) => {}
13368 _ = shared.shutdown_notify.notified() => return Ok(()),
13369 }
13370 }
13371 }
13372 }
13373}
13374
13375const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13377const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13379
13380fn call_home_keepalive() -> socket2::TcpKeepalive {
13381 let keepalive = socket2::TcpKeepalive::new()
13382 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13383 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13384 #[cfg(not(windows))]
13385 let keepalive = keepalive.with_retries(3);
13386 keepalive
13387}
13388
13389fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13390 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13391}
13392
13393pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13395 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13396}
13397
13398fn call_home_backoff(attempt: usize) -> Duration {
13400 Duration::from_millis(
13401 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13402 )
13403}
13404
13405
13406async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13409 #[cfg(feature = "wireguard")]
13410 {
13411 let std_stream = tokio::task::spawn_blocking(move || {
13412 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13413 })
13414 .await
13415 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13416 .map_err(NodeServerError::CallHomeTransport)?;
13417 std_stream
13418 .set_nonblocking(true)
13419 .map_err(NodeServerError::CallHomeTransport)?;
13420 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13421 }
13422 #[cfg(not(feature = "wireguard"))]
13423 {
13424 tokio::net::TcpStream::connect(relay)
13425 .await
13426 .map_err(NodeServerError::CallHomeTransport)
13427 }
13428}
13429
13430async fn dial_relay_and_serve(
13432 relay: std::net::SocketAddr,
13433 identity: Option<&hatchery_enroll::NodeIdentity>,
13434 shared: Arc<NodeShared>,
13435 preauth_permit: OwnedSemaphorePermit,
13436) -> Result<(), NodeServerError> {
13437 let mut stream = dial_call_home(relay).await?;
13438 let _ = stream.set_nodelay(true);
13442 if let Err(error) = enable_call_home_keepalive(&stream) {
13448 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13449 }
13450 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13451 .await
13452 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13453 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13454 let mut session_token = None;
13455 if let Some(identity) = identity {
13456 let mut magic = [0_u8; 4];
13460 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13461 .await
13462 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13463 .map_err(NodeServerError::CallHomeTransport)?;
13464 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13465 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13466 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13467 .await
13468 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13469 .map_err(NodeServerError::CallHomeTransport)?;
13470 let (answer, token) = identity
13471 .answer_challenge(shared.node_id.as_str(), &frame)
13472 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13473 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13474 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13475 session_token = Some(token);
13476 }
13477 }
13478 serve_connection(stream, shared, session_token, preauth_permit).await
13479}
13480
13481async fn accept_connections_inner(
13482 endpoint: &str,
13483 shared: Arc<NodeShared>,
13484 connections: &mut JoinSet<Result<(), NodeServerError>>,
13485) -> Result<(), NodeServerError> {
13486 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13487 loop {
13488 while connections.try_join_next().is_some() {}
13489 if shared.shutdown.load(Ordering::Acquire) {
13490 return Ok(());
13491 }
13492 let preauth_permit = tokio::select! {
13493 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13494 permit.map_err(|_| NodeServerError::PreauthClosed)?
13495 }
13496 _ = shared.shutdown_notify.notified() => return Ok(()),
13497 };
13498 let server = tokio::select! {
13499 result = listener.accept() => result?,
13500 _ = shared.shutdown_notify.notified() => return Ok(()),
13501 };
13502 let connection_shared = Arc::clone(&shared);
13503 connections.spawn(async move {
13504 serve_connection(server, connection_shared, None, preauth_permit).await
13505 });
13506 if shared.shutdown.load(Ordering::Acquire) {
13507 return Ok(());
13508 }
13509 }
13510}
13511
13512async fn serve_connection<S>(
13520 mut pipe: S,
13521 shared: Arc<NodeShared>,
13522 session_token: Option<String>,
13523 preauth_permit: OwnedSemaphorePermit,
13524) -> Result<(), NodeServerError>
13525where
13526 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13527{
13528 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13530 let hello_frame = timeout(
13531 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13532 read_json_frame_limited_body_timeout(
13533 &mut pipe,
13534 MAX_NODE_HELLO_FRAME_BYTES,
13535 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13536 ),
13537 )
13538 .await
13539 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13540 let ClientFrame::Hello(hello) = hello_frame else {
13541 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13542 };
13543 if hello.build_stamp != BUILD_STAMP {
13544 return Err(NodeServerError::Handshake(format!(
13545 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13546 hello.build_stamp,
13547 )));
13548 }
13549 let compatibility = match hello.compatibility.as_ref() {
13550 Some(offer) => {
13551 let mut selected = node_compatibility_support(&shared)?
13552 .negotiate(offer)
13553 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13554 project_negotiated_provider_ids(&mut selected);
13555 Some(selected)
13556 }
13557 None => None,
13558 };
13559 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13560 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13561 (Some(offer), Some(selected)) => negotiated_auth_proof(
13562 auth_token,
13563 AuthDirection::Server,
13564 hello.role,
13565 &hello.client_nonce,
13566 &server_nonce,
13567 offer,
13568 selected,
13569 ),
13570 (None, None) => auth_proof(
13571 auth_token,
13572 AuthDirection::Server,
13573 hello.role,
13574 &hello.client_nonce,
13575 &server_nonce,
13576 ),
13577 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13578 }
13579 .map_err(NodeServerError::Authentication)?;
13580 write_json_frame_limited(
13581 &mut pipe,
13582 &ServerFrame::Challenge(ServerChallenge {
13583 build_stamp: BUILD_STAMP.to_owned(),
13584 server_nonce,
13585 server_proof,
13586 compatibility: compatibility.clone(),
13587 }),
13588 MAX_NODE_HELLO_FRAME_BYTES,
13589 )
13590 .await?;
13591 let authentication = timeout(
13592 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13593 read_json_frame_limited_body_timeout(
13594 &mut pipe,
13595 MAX_NODE_HELLO_FRAME_BYTES,
13596 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13597 ),
13598 )
13599 .await
13600 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13601 let ClientFrame::Authenticate(authentication) = authentication else {
13602 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13603 };
13604 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13605 (Some(offer), Some(selected)) => negotiated_auth_proof(
13606 auth_token,
13607 AuthDirection::Client,
13608 hello.role,
13609 &hello.client_nonce,
13610 &server_nonce,
13611 offer,
13612 selected,
13613 ),
13614 (None, None) => auth_proof(
13615 auth_token,
13616 AuthDirection::Client,
13617 hello.role,
13618 &hello.client_nonce,
13619 &server_nonce,
13620 ),
13621 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13622 }
13623 .map_err(NodeServerError::Authentication)?;
13624 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13625 return Err(NodeServerError::Handshake("access denied".to_owned()));
13626 }
13627 let selected_capabilities = compatibility
13628 .as_ref()
13629 .map(|selected| selected.capabilities.clone())
13630 .unwrap_or_default();
13631 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13632 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13633 });
13634 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13635 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13636 });
13637 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13638 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13639 });
13640 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13641 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13642 });
13643 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13644 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13645 });
13646 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13647 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13648 }) && selected_capabilities.iter().any(|capability| {
13649 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13650 });
13651 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13652 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13653 });
13654 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13655 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13656 });
13657 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13658 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13659 });
13660 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13661 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13662 });
13663 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13664 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13665 });
13666 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13667 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13668 });
13669 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13670 .try_acquire_owned()
13671 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13672 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13673 let _release_guard = ControllerReleaseGuard {
13674 shared: Arc::clone(&shared),
13675 connection_id,
13676 };
13677 drop(preauth_permit);
13678 let _authenticated_permit = authenticated_permit;
13679 let mut event_rx = shared.event_tx.subscribe();
13680 let mut terminal_event_rx = terminal_event_subscription(
13681 &shared,
13682 include_terminal_frame_events,
13683 );
13684 let mut agent_stream_event_rx = agent_stream_event_subscription(
13685 &shared,
13686 include_agent_stream_events,
13687 );
13688 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13689 &shared,
13690 include_harness_mcp_proxy,
13691 );
13692 write_json_frame(
13693 &mut pipe,
13694 &ServerFrame::Hello(NodeHello {
13695 build_stamp: BUILD_STAMP.to_owned(),
13696 incarnation_id: shared.incarnation_id,
13697 connection_id,
13698 role: hello.role,
13699 event_sequence: shared.current_sequence(),
13700 controller: shared.controller_state(),
13701 snapshot: snapshot_for_wire(
13702 &shared,
13703 include_provider_runtime_status,
13704 include_open_provider_ids,
13705 include_spawn_profiles,
13706 include_managed_worktrees,
13707 include_child_environment_profiles,
13708 include_session_bundles,
13709 include_history_context_packs,
13710 include_agent_progress,
13711 include_session_task_correlation,
13712 ),
13713 compatibility,
13714 }),
13715 )
13716 .await?;
13717 let (mut reader, mut writer) = tokio::io::split(pipe);
13718 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13719 let reader_task = tokio::spawn(async move {
13720 loop {
13721 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13722 &mut reader,
13723 MAX_NODE_CLIENT_FRAME_BYTES,
13724 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13725 )
13726 .await;
13727 let terminal = frame.is_err();
13728 if frame_tx.send(frame).await.is_err() || terminal {
13729 break;
13730 }
13731 }
13732 });
13733 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13734 let mut pending_events = Vec::new();
13735 let mut resync_required = false;
13736 let mut discard_events_through = 0_u64;
13737 loop {
13738 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13743 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13744 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13745 for _ in 0..durable_drain_budget {
13746 match event_rx.try_recv() {
13747 Ok(event) => queue_connection_event(
13748 &mut pending_events,
13749 event,
13750 discard_events_through,
13751 ),
13752 Err(broadcast::error::TryRecvError::Empty) => break,
13753 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13754 resync_required = true;
13755 break;
13756 }
13757 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13758 }
13759 }
13760 if let Some(receiver) = terminal_event_rx.as_mut() {
13761 match receiver.try_recv() {
13762 Ok(events) => queue_connection_event_batch(
13763 &mut pending_events,
13764 &events,
13765 discard_events_through,
13766 ),
13767 Err(broadcast::error::TryRecvError::Empty) => {}
13768 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13769 resync_required = true;
13770 }
13771 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13772 }
13773 }
13774 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13775 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13785 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13786 for _ in 0..agent_stream_drain_budget {
13787 match receiver.try_recv() {
13788 Ok(events) => queue_connection_event_batch(
13789 &mut pending_events,
13790 &events,
13791 discard_events_through,
13792 ),
13793 Err(broadcast::error::TryRecvError::Empty) => break,
13794 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13795 tracing::warn!(
13796 connection_id,
13797 dropped,
13798 "agent stream event subscription lagged; forcing a resync",
13799 );
13800 resync_required = true;
13801 break;
13802 }
13803 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13804 }
13805 }
13806 }
13807 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13808 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13814 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13815 for _ in 0..harness_mcp_proxy_drain_budget {
13816 match receiver.try_recv() {
13817 Ok(events) => queue_connection_event_batch(
13818 &mut pending_events,
13819 &events,
13820 discard_events_through,
13821 ),
13822 Err(broadcast::error::TryRecvError::Empty) => break,
13823 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13824 tracing::warn!(
13825 connection_id,
13826 dropped,
13827 "harness MCP proxy event subscription lagged; forcing a resync",
13828 );
13829 resync_required = true;
13830 break;
13831 }
13832 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13833 }
13834 }
13835 }
13836 if resync_required {
13837 let event = resync_required_event(&shared);
13838 apply_connection_resync_watermark(
13839 &mut pending_events,
13840 &mut discard_events_through,
13841 event.sequence,
13842 );
13843 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13844 resync_required = false;
13845 continue;
13846 }
13847 if !pending_events.is_empty() {
13848 pending_events.sort_unstable_by_key(|event| event.sequence);
13849 let burst_len = connection_event_burst_len(pending_events.len());
13850 shared
13851 .connection_events_sent
13852 .fetch_add(burst_len as u64, Ordering::Relaxed);
13853 for event in pending_events.drain(..burst_len) {
13854 let event = if include_managed_worktrees {
13855 Some(event)
13856 } else {
13857 project_event_without_managed_worktrees(event)
13858 };
13859 let event = event.and_then(|event| if include_open_provider_ids {
13860 Some(event)
13861 } else {
13862 project_event_legacy_provider_ids(&shared, event)
13863 });
13864 let event = event.map(|event| if include_child_environment_profiles {
13865 event
13866 } else {
13867 project_event_without_child_environment_profile(event)
13868 });
13869 let event = event.map(|event| if include_session_bundles {
13870 event
13871 } else {
13872 project_event_without_session_bundle(event)
13873 });
13874 let event = event.map(|event| {
13875 project_event_history_for_wire(event, include_history_context_packs)
13876 });
13877 let event = event.map(|event| if include_history_context_packs {
13878 event
13879 } else {
13880 project_event_without_context_pack(event)
13881 });
13882 let event = event.map(|event| if include_session_task_correlation {
13883 event
13884 } else {
13885 project_event_without_session_task_binding(event)
13886 });
13887 let exact_harness_controller = hello.role == ClientRole::Operator
13888 && shared.controller_state().is_some_and(|controller| {
13889 controller.connection_id == connection_id
13890 });
13891 let event = event.and_then(|event| {
13892 if include_harness_mcp_proxy && exact_harness_controller {
13893 Some(event)
13894 } else {
13895 project_event_without_harness_mcp_proxy(event)
13896 }
13897 });
13898 if let Some(event) = event {
13899 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13900 }
13901 }
13902 }
13903 tokio::select! {
13904 biased;
13905 frame = frame_rx.recv() => {
13906 let frame = match frame {
13907 Some(frame) => frame,
13908 None => break,
13909 };
13910 let frame = match frame {
13911 Ok(frame) => frame,
13912 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13913 Err(error) => return Err(error.into()),
13914 };
13915 let ClientFrame::Request(request) = frame else {
13916 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13917 };
13918 #[cfg(feature = "fixture")]
13919 let probe_spawn_managed_worktree_v2 = matches!(
13920 &request.request,
13921 NodeRequest::SpawnManagedWorktreeV2 { .. }
13922 );
13923 let requires_open_provider_ids =
13924 request_requires_open_provider_ids(&shared, &request.request);
13925 let requires_child_environment_profile =
13926 request_requires_child_environment_profile(&shared, &request.request);
13927 let requires_session_bundle =
13928 request_requires_session_bundle(&shared, &request.request);
13929 let requires_history_context_pack =
13930 request_requires_history_context_pack(&shared, &request.request);
13931 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13932 ResponseEnvelope {
13933 request_id: request.request_id,
13934 result: Err(failure(
13935 NodeFailureCode::UnsupportedCapability,
13936 "open provider IDs were not negotiated",
13937 )),
13938 }
13939 } else if requires_child_environment_profile
13940 && !include_child_environment_profiles
13941 {
13942 ResponseEnvelope {
13943 request_id: request.request_id,
13944 result: Err(failure(
13945 NodeFailureCode::UnsupportedCapability,
13946 "child environment profiles were not negotiated",
13947 )),
13948 }
13949 } else if requires_session_bundle && !include_session_bundles {
13950 ResponseEnvelope {
13951 request_id: request.request_id,
13952 result: Err(failure(
13953 NodeFailureCode::UnsupportedCapability,
13954 "session bundles were not negotiated",
13955 )),
13956 }
13957 } else if requires_history_context_pack && !include_history_context_packs {
13958 ResponseEnvelope {
13959 request_id: request.request_id,
13960 result: Err(failure(
13961 NodeFailureCode::UnsupportedCapability,
13962 "history context packs were not negotiated",
13963 )),
13964 }
13965 } else if request_uses_unnegotiated_capability(
13966 &request.request,
13967 &selected_capabilities,
13968 )
13969 {
13970 ResponseEnvelope {
13971 request_id: request.request_id,
13972 result: Err(failure(
13973 NodeFailureCode::UnsupportedCapability,
13974 "request capability was not negotiated",
13975 )),
13976 }
13977 } else {
13978 process_request(&shared, connection_id, hello.role, request).await
13979 };
13980 #[cfg(feature = "fixture")]
13981 if probe_spawn_managed_worktree_v2 {
13982 shared
13983 .fixture_spawn_managed_worktree_v2_failure_probe
13984 .record(reply.result.as_ref().err().map(|failure| failure.code));
13985 }
13986 if !include_provider_runtime_status {
13987 clear_response_provider_runtime_status(&mut reply);
13988 }
13989 if !include_managed_worktrees {
13990 project_response_without_managed_worktrees(&mut reply);
13991 }
13992 if !include_open_provider_ids {
13993 project_response_legacy_provider_ids(&shared, &mut reply);
13994 }
13995 if !include_child_environment_profiles {
13996 project_response_without_child_environment_profile(&mut reply);
13997 }
13998 if !include_session_bundles {
13999 project_response_without_session_bundle(&mut reply);
14000 }
14001 project_response_history_for_wire(
14002 &mut reply,
14003 include_history_context_packs,
14004 );
14005 if !include_history_context_packs {
14006 project_response_without_context_pack(&mut reply);
14007 }
14008 if !include_agent_progress {
14009 project_response_without_agent_progress(&mut reply);
14010 }
14011 if !include_session_task_correlation {
14012 project_response_without_session_task_binding(&mut reply);
14013 }
14014 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14015 }
14016 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14017 event = event_rx.recv() => {
14018 match event {
14019 Ok(event) => queue_connection_event(
14020 &mut pending_events,
14021 event,
14022 discard_events_through,
14023 ),
14024 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14025 Err(broadcast::error::RecvError::Closed) => break,
14026 }
14027 }
14028 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14029 match events {
14030 Ok(events) => queue_connection_event_batch(
14031 &mut pending_events,
14032 &events,
14033 discard_events_through,
14034 ),
14035 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14036 Err(broadcast::error::RecvError::Closed) => break,
14037 }
14038 }
14039 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14040 match events {
14041 Ok(events) => queue_connection_event_batch(
14042 &mut pending_events,
14043 &events,
14044 discard_events_through,
14045 ),
14046 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14047 Err(broadcast::error::RecvError::Closed) => break,
14048 }
14049 }
14050 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14051 match events {
14052 Ok(events) => queue_connection_event_batch(
14053 &mut pending_events,
14054 &events,
14055 discard_events_through,
14056 ),
14057 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14058 Err(broadcast::error::RecvError::Closed) => break,
14059 }
14060 }
14061 _ = shared.shutdown_notify.notified() => break,
14062 }
14063 }
14064 Ok(())
14065}
14066
14067async fn receive_terminal_event_batch(
14068 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14069) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14070 match receiver {
14071 Some(receiver) => receiver.recv().await,
14072 None => std::future::pending().await,
14073 }
14074}
14075
14076fn terminal_event_subscription(
14077 shared: &NodeShared,
14078 enabled: bool,
14079) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14080 enabled.then(|| shared.terminal_event_tx.subscribe())
14081}
14082
14083fn agent_stream_event_subscription(
14087 shared: &NodeShared,
14088 enabled: bool,
14089) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14090 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14091}
14092
14093fn harness_mcp_proxy_event_subscription(
14098 shared: &NodeShared,
14099 enabled: bool,
14100) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14101 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14102}
14103
14104fn queue_connection_event(
14105 pending: &mut Vec<NodeEventEnvelope>,
14106 event: NodeEventEnvelope,
14107 discard_through: u64,
14108) {
14109 if event.sequence <= discard_through
14110 && !event.event.requires_harness_mcp_proxy_capability()
14111 {
14112 return;
14113 }
14114 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14115 if let Some(index) = pending.iter().position(|current| {
14116 matches!(
14117 ¤t.event,
14118 NodeEvent::TerminalFrame {
14119 address: current_address,
14120 ..
14121 } if current_address == address
14122 )
14123 }) {
14124 pending.remove(index);
14125 }
14126 }
14127 pending.push(event);
14128}
14129
14130fn project_event_without_harness_mcp_proxy(
14131 envelope: NodeEventEnvelope,
14132) -> Option<NodeEventEnvelope> {
14133 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14134}
14135
14136fn queue_connection_event_batch(
14137 pending: &mut Vec<NodeEventEnvelope>,
14138 events: &[NodeEventEnvelope],
14139 discard_through: u64,
14140) {
14141 for event in events {
14142 queue_connection_event(pending, event.clone(), discard_through);
14143 }
14144}
14145
14146fn apply_connection_resync_watermark(
14147 pending: &mut Vec<NodeEventEnvelope>,
14148 discard_through: &mut u64,
14149 marker_sequence: u64,
14150) {
14151 *discard_through = (*discard_through).max(marker_sequence);
14152 pending.retain(|event| event.sequence > *discard_through);
14153}
14154
14155fn connection_event_burst_len(pending_len: usize) -> usize {
14156 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14157}
14158
14159fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14160 let history = shared
14161 .history
14162 .lock()
14163 .unwrap_or_else(|poisoned| poisoned.into_inner());
14164 let sequence = history.last_sequence;
14165 let oldest_available_sequence = history.replay_floor_sequence;
14166 NodeEventEnvelope {
14167 sequence,
14168 event: NodeEvent::ResyncRequired {
14169 oldest_available_sequence,
14170 },
14171 }
14172}
14173
14174fn node_compatibility_support(
14175 shared: &NodeShared,
14176) -> Result<NodeCompatibilitySupport, NodeServerError> {
14177 let mut support = node_compatibility_support_for_manifest(
14178 &shared.provider_contracts,
14179 &shared.provider_adapter_contracts,
14180 )?;
14181 if shared
14182 .delivery_store
14183 .lock()
14184 .unwrap_or_else(|poisoned| poisoned.into_inner())
14185 .is_some()
14186 {
14187 support.capabilities.push(
14188 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14189 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14190 );
14191 }
14192 if shared.harness_mcp_registry.is_some() {
14193 support.capabilities.push(
14194 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14195 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14196 );
14197 }
14198 Ok(support)
14199}
14200
14201fn node_compatibility_support_for_manifest(
14202 provider_contracts: &[ProviderContractSupport],
14203 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14204) -> Result<NodeCompatibilitySupport, NodeServerError> {
14205 Ok(NodeCompatibilitySupport {
14206 build_stamp: BUILD_STAMP.to_owned(),
14207 capabilities: baseline_capabilities()?,
14208 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14209 path_semantics: platform::path_semantics(),
14210 local_transport: platform::local_transport(),
14211 state_schema: StateSchemaSupport {
14212 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14213 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14214 },
14215 provider_contracts: provider_contracts.to_vec(),
14216 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14217 })
14218}
14219
14220fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14221 let capabilities = [
14222 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14223 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14224 NODE_REPOSITORY_PATH_CAPABILITY,
14225 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14226 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14227 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14228 NODE_GIT_READ_CAPABILITY,
14229 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14230 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14231 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14232 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14233 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14234 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14235 NODE_ACP_CONTROL_CAPABILITY,
14236 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14237 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14238 NODE_WORKTREE_SELECTION_CAPABILITY,
14239 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14240 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14241 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14242 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14243 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14244 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14245 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14246 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14247 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14248 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14249 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14250 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14251 ]
14252 .into_iter()
14253 .map(|capability| {
14254 CapabilityId::new(capability)
14255 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14256 })
14257 .collect::<Result<Vec<_>, _>>()?;
14258 #[cfg(windows)]
14259 let capabilities = {
14260 let mut capabilities = capabilities;
14261 capabilities.push(
14262 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14263 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14264 );
14265 capabilities
14266 };
14267 Ok(capabilities)
14268}
14269
14270fn request_uses_unnegotiated_capability(
14271 request: &NodeRequest,
14272 selected_capabilities: &[CapabilityId],
14273) -> bool {
14274 let selected = |required: &str| {
14275 selected_capabilities
14276 .iter()
14277 .any(|capability| capability.as_str() == required)
14278 };
14279 request
14280 .required_capability()
14281 .is_some_and(|required| !selected(required))
14282 || (request.requires_worktree_selection_capability()
14283 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14284 || (request.requires_spawn_spec_defaults_overrides_capability()
14285 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14286 || (request.requires_spawn_profile_revision_capability()
14287 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14288 || (request.requires_child_environment_profile_capability()
14289 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14290 || (request.requires_session_bundle_materialization_capability()
14291 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14292 || (request.requires_history_context_pack_capability()
14293 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14294}
14295
14296fn snapshot_for_wire(
14297 shared: &NodeShared,
14298 include_provider_runtime_status: bool,
14299 include_open_provider_ids: bool,
14300 include_spawn_profiles: bool,
14301 include_managed_worktrees: bool,
14302 include_child_environment_profiles: bool,
14303 include_session_bundles: bool,
14304 include_history_context_packs: bool,
14305 include_agent_progress: bool,
14306 include_session_task_correlation: bool,
14307) -> NodeSnapshot {
14308 let mut snapshot = shared.snapshot();
14309 if !include_provider_runtime_status {
14310 snapshot.provider_runtime_statuses.clear();
14311 }
14312 if !include_open_provider_ids {
14313 project_snapshot_legacy_provider_ids(&mut snapshot);
14314 }
14315 if !include_managed_worktrees {
14316 snapshot.managed_worktrees.clear();
14317 for workspace in &mut snapshot.workspaces {
14318 workspace.managed_worktree_profiles = None;
14319 }
14320 }
14321 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14322 if !include_spawn_profiles {
14323 inventory.spawn_profiles = None;
14324 }
14325 if !include_session_bundles {
14326 inventory.bundles = None;
14327 }
14328 if inventory.spawn_profiles.is_none()
14329 && inventory.bundles.is_none()
14330 && inventory.network_allowlists.is_none()
14331 {
14332 snapshot.launch_inventory = None;
14333 }
14334 }
14335 if !include_child_environment_profiles {
14336 clear_snapshot_child_environment_profiles(&mut snapshot);
14337 }
14338 if !include_session_bundles {
14339 clear_snapshot_session_bundles(&mut snapshot);
14340 }
14341 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14342 if !include_history_context_packs {
14343 clear_snapshot_context_packs(&mut snapshot);
14344 }
14345 if !include_agent_progress {
14346 snapshot.agent_progress.clear();
14347 }
14348 if !include_session_task_correlation {
14349 clear_snapshot_session_task_bindings(&mut snapshot);
14350 }
14351 snapshot
14352}
14353
14354fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14355 for record in &mut snapshot.session_records {
14356 record.task_binding = None;
14357 }
14358}
14359
14360fn project_event_without_session_task_binding(
14361 mut envelope: NodeEventEnvelope,
14362) -> NodeEventEnvelope {
14363 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14364 record.task_binding = None;
14365 }
14366 envelope
14367}
14368
14369fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14370 let Ok(response) = reply.result.as_mut() else { return };
14371 match response {
14372 NodeResponse::Snapshot { snapshot, .. } => {
14373 clear_snapshot_session_task_bindings(snapshot);
14374 }
14375 NodeResponse::Resync { snapshot, events, .. } => {
14376 clear_snapshot_session_task_bindings(snapshot);
14377 for event in events {
14378 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14379 record.task_binding = None;
14380 }
14381 }
14382 }
14383 NodeResponse::SessionRecordUpdated { record }
14384 | NodeResponse::ProviderSessionIndexed { record }
14385 | NodeResponse::NativeSessionIndexed { record, .. }
14386 | NodeResponse::SessionRecordResumed { record, .. } => {
14387 record.task_binding = None;
14388 }
14389 _ => {}
14390 }
14391}
14392
14393fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14394 let Ok(response) = reply.result.as_mut() else { return };
14395 match response {
14396 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14397 snapshot.agent_progress.clear();
14398 }
14399 _ => {}
14400 }
14401}
14402
14403fn project_event_without_managed_worktrees(
14404 mut envelope: NodeEventEnvelope,
14405) -> Option<NodeEventEnvelope> {
14406 if matches!(
14407 envelope.event,
14408 NodeEvent::ManagedWorktreeUpserted { .. }
14409 | NodeEvent::ManagedWorktreeRemoved { .. }
14410 ) {
14411 return None;
14412 }
14413 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14414 workspace.managed_worktree_profiles = None;
14415 }
14416 Some(envelope)
14417}
14418
14419fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14420 if matches!(
14421 reply.result,
14422 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14423 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14424 ) {
14425 reply.result = Err(failure(
14426 NodeFailureCode::UnsupportedCapability,
14427 "managed worktree capability was not negotiated",
14428 ));
14429 return;
14430 }
14431 let Ok(response) = reply.result.as_mut() else { return };
14432 match response {
14433 NodeResponse::Snapshot { snapshot, .. } => {
14434 snapshot.managed_worktrees.clear();
14435 for workspace in &mut snapshot.workspaces {
14436 workspace.managed_worktree_profiles = None;
14437 }
14438 }
14439 NodeResponse::Resync { snapshot, events, .. } => {
14440 snapshot.managed_worktrees.clear();
14441 for workspace in &mut snapshot.workspaces {
14442 workspace.managed_worktree_profiles = None;
14443 }
14444 *events = std::mem::take(events)
14445 .into_iter()
14446 .filter_map(project_event_without_managed_worktrees)
14447 .collect();
14448 }
14449 NodeResponse::WorkspaceRegistered { workspace }
14450 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14451 | NodeResponse::WorktreeCreated { workspace, .. } => {
14452 workspace.managed_worktree_profiles = None;
14453 }
14454 NodeResponse::WorkspaceInspected { inspection } => {
14455 inspection.git.managed_worktree = None;
14456 }
14457 _ => {}
14458 }
14459}
14460
14461fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14462 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14463 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14464 });
14465 if includes_open_provider_ids {
14466 return;
14467 }
14468 compatibility
14469 .provider_contracts
14470 .retain(|contract| provider_id_is_legacy(&contract.provider));
14471 compatibility
14472 .provider_adapter_contracts
14473 .retain(|contract| provider_id_is_legacy(&contract.provider));
14474}
14475
14476fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14477 if let Some(spec) = match request {
14478 NodeRequest::SpawnSpec { spec }
14479 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14480 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14481 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14482 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14483 _ => None,
14484 } {
14485 return shared
14486 .resolve_spawn_spec(spec)
14487 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14488 }
14489 request_requires_open_provider_ids_with(
14490 request,
14491 |session| shared.validate_address(session).ok(),
14492 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14493 )
14494}
14495
14496fn request_requires_child_environment_profile(
14497 shared: &NodeShared,
14498 request: &NodeRequest,
14499) -> bool {
14500 if let Some(spec) = match request {
14501 NodeRequest::SpawnSpec { spec }
14502 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14503 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14504 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14505 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14506 _ => None,
14507 } {
14508 return shared
14509 .resolve_spawn_spec(spec)
14510 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14511 }
14512 match request {
14513 NodeRequest::Resume { session, .. }
14514 | NodeRequest::Prompt { session, .. }
14515 | NodeRequest::Paste { session, .. }
14516 | NodeRequest::Input { session, .. }
14517 | NodeRequest::TerminalBytes { session, .. }
14518 | NodeRequest::TerminalControl { session, .. }
14519 | NodeRequest::Resize { session, .. }
14520 | NodeRequest::Interrupt { session }
14521 | NodeRequest::Stop { session, .. }
14522 | NodeRequest::Remove { session }
14523 | NodeRequest::ResolveInteraction { session, .. }
14524 | NodeRequest::SetSessionMode { session, .. }
14525 | NodeRequest::SetSessionConfigOption { session, .. }
14526 | NodeRequest::SetSessionModel { session, .. }
14527 | NodeRequest::DiscoverHistory { session, .. }
14528 | NodeRequest::LoadHistory { session, .. }
14529 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14530 | NodeRequest::ExportContextPack { session } => shared
14531 .session_bindings
14532 .lock()
14533 .unwrap_or_else(|poisoned| poisoned.into_inner())
14534 .get(&session.session.instance_id)
14535 .filter(|binding| {
14536 binding.workspace_id == session.workspace_id
14537 && binding.generation == session.session.generation
14538 })
14539 .map_or(true, |binding| binding.environment_profile.is_some()),
14540 NodeRequest::RenameSessionRecord { record_id, .. }
14541 | NodeRequest::SetSessionTask { record_id, .. }
14542 | NodeRequest::ResumeSessionRecord { record_id, .. }
14543 | NodeRequest::ForgetSessionRecord { record_id } => shared
14544 .record(record_id)
14545 .map_or(true, |record| record.environment_profile.is_some()),
14546 NodeRequest::ArmHarnessMcpReservation { .. }
14547 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14548 | NodeRequest::ActivateHarnessMcpReservation { .. }
14549 | NodeRequest::AbortHarnessMcpReservation { .. }
14550 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14551 | NodeRequest::RejectHarnessMcpCall { .. }
14552 | NodeRequest::Snapshot
14553 | NodeRequest::Resync { .. }
14554 | NodeRequest::BeginDeliveryStage { .. }
14555 | NodeRequest::PutDeliveryBlobChunk { .. }
14556 | NodeRequest::CommitDeliveryStage { .. }
14557 | NodeRequest::AbortDeliveryStage { .. }
14558 | NodeRequest::BrowseHostDirectories { .. }
14559 | NodeRequest::InspectWorkspace { .. }
14560 | NodeRequest::ReadWorkspaceFile { .. }
14561 | NodeRequest::WriteWorkspaceFile { .. }
14562 | NodeRequest::CreateWorkspaceFile { .. }
14563 | NodeRequest::CreateWorkspaceDirectory { .. }
14564 | NodeRequest::ReadGitHistory { .. }
14565 | NodeRequest::ReadGitDiff { .. }
14566 | NodeRequest::AcquireController { .. }
14567 | NodeRequest::ReleaseController
14568 | NodeRequest::Spawn { .. }
14569 | NodeRequest::SpawnWithOptions { .. }
14570 | NodeRequest::SpawnSpec { .. }
14571 | NodeRequest::RegisterWorkspace { .. }
14572 | NodeRequest::CreateStandaloneWorkspace { .. }
14573 | NodeRequest::UnregisterWorkspace { .. }
14574 | NodeRequest::CreateWorktree { .. }
14575 | NodeRequest::RemoveWorktree { .. }
14576 | NodeRequest::SpawnManagedWorktree { .. }
14577 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14578 | NodeRequest::CleanupManagedWorktree { .. }
14579 | NodeRequest::IndexProviderSession { .. }
14580 | NodeRequest::IndexNativeSession { .. }
14581 | NodeRequest::CatalogNativeSessions { .. }
14582 | NodeRequest::PageNativeSessions { .. }
14583 | NodeRequest::PreviewNativeSession { .. }
14584 | NodeRequest::PreviewSessionRecord { .. }
14585 | NodeRequest::ForgetContextPack { .. }
14586 | NodeRequest::ResolveDurableContextPack { .. }
14587 | NodeRequest::ReadContextPack { .. }
14588 | NodeRequest::Shutdown => false,
14589 }
14590}
14591
14592fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14593 if let Some(spec) = match request {
14594 NodeRequest::SpawnSpec { spec }
14595 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14596 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14597 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14598 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14599 _ => None,
14600 } {
14601 return shared
14602 .resolve_spawn_spec(spec)
14603 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14604 }
14605 match request {
14606 NodeRequest::Resume { session, .. }
14607 | NodeRequest::Prompt { session, .. }
14608 | NodeRequest::Paste { session, .. }
14609 | NodeRequest::Input { session, .. }
14610 | NodeRequest::TerminalBytes { session, .. }
14611 | NodeRequest::TerminalControl { session, .. }
14612 | NodeRequest::Resize { session, .. }
14613 | NodeRequest::Interrupt { session }
14614 | NodeRequest::Stop { session, .. }
14615 | NodeRequest::Remove { session }
14616 | NodeRequest::ResolveInteraction { session, .. }
14617 | NodeRequest::SetSessionMode { session, .. }
14618 | NodeRequest::SetSessionConfigOption { session, .. }
14619 | NodeRequest::SetSessionModel { session, .. }
14620 | NodeRequest::DiscoverHistory { session, .. }
14621 | NodeRequest::LoadHistory { session, .. }
14622 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14623 | NodeRequest::ExportContextPack { session } => shared
14624 .session_bindings
14625 .lock()
14626 .unwrap_or_else(|poisoned| poisoned.into_inner())
14627 .get(&session.session.instance_id)
14628 .filter(|binding| {
14629 binding.workspace_id == session.workspace_id
14630 && binding.generation == session.session.generation
14631 })
14632 .map_or(true, |binding| binding.bundle.is_some()),
14633 NodeRequest::RenameSessionRecord { record_id, .. }
14634 | NodeRequest::SetSessionTask { record_id, .. }
14635 | NodeRequest::ResumeSessionRecord { record_id, .. }
14636 | NodeRequest::ForgetSessionRecord { record_id } => shared
14637 .record(record_id)
14638 .map_or(true, |record| record.bundle.is_some()),
14639 NodeRequest::ArmHarnessMcpReservation { .. }
14640 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14641 | NodeRequest::ActivateHarnessMcpReservation { .. }
14642 | NodeRequest::AbortHarnessMcpReservation { .. }
14643 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14644 | NodeRequest::RejectHarnessMcpCall { .. }
14645 | NodeRequest::Snapshot
14646 | NodeRequest::Resync { .. }
14647 | NodeRequest::BeginDeliveryStage { .. }
14648 | NodeRequest::PutDeliveryBlobChunk { .. }
14649 | NodeRequest::CommitDeliveryStage { .. }
14650 | NodeRequest::AbortDeliveryStage { .. }
14651 | NodeRequest::BrowseHostDirectories { .. }
14652 | NodeRequest::InspectWorkspace { .. }
14653 | NodeRequest::ReadWorkspaceFile { .. }
14654 | NodeRequest::WriteWorkspaceFile { .. }
14655 | NodeRequest::CreateWorkspaceFile { .. }
14656 | NodeRequest::CreateWorkspaceDirectory { .. }
14657 | NodeRequest::ReadGitHistory { .. }
14658 | NodeRequest::ReadGitDiff { .. }
14659 | NodeRequest::AcquireController { .. }
14660 | NodeRequest::ReleaseController
14661 | NodeRequest::Spawn { .. }
14662 | NodeRequest::SpawnWithOptions { .. }
14663 | NodeRequest::SpawnSpec { .. }
14664 | NodeRequest::RegisterWorkspace { .. }
14665 | NodeRequest::CreateStandaloneWorkspace { .. }
14666 | NodeRequest::UnregisterWorkspace { .. }
14667 | NodeRequest::CreateWorktree { .. }
14668 | NodeRequest::RemoveWorktree { .. }
14669 | NodeRequest::SpawnManagedWorktree { .. }
14670 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14671 | NodeRequest::CleanupManagedWorktree { .. }
14672 | NodeRequest::IndexProviderSession { .. }
14673 | NodeRequest::IndexNativeSession { .. }
14674 | NodeRequest::CatalogNativeSessions { .. }
14675 | NodeRequest::PageNativeSessions { .. }
14676 | NodeRequest::PreviewNativeSession { .. }
14677 | NodeRequest::PreviewSessionRecord { .. }
14678 | NodeRequest::ForgetContextPack { .. }
14679 | NodeRequest::ResolveDurableContextPack { .. }
14680 | NodeRequest::ReadContextPack { .. }
14681 | NodeRequest::Shutdown => false,
14682 }
14683}
14684
14685fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14686 if request.requires_history_context_pack_capability() {
14687 return true;
14688 }
14689 if let Some(spec) = match request {
14690 NodeRequest::SpawnSpec { spec }
14691 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14692 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14693 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14694 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14695 _ => None,
14696 } {
14697 return shared
14698 .resolve_spawn_spec(spec)
14699 .map_or(true, |resolved| resolved.context_id.is_some());
14700 }
14701 match request {
14702 NodeRequest::Resume { session, .. }
14703 | NodeRequest::Prompt { session, .. }
14704 | NodeRequest::Paste { session, .. }
14705 | NodeRequest::Input { session, .. }
14706 | NodeRequest::TerminalBytes { session, .. }
14707 | NodeRequest::TerminalControl { session, .. }
14708 | NodeRequest::Resize { session, .. }
14709 | NodeRequest::Interrupt { session }
14710 | NodeRequest::Stop { session, .. }
14711 | NodeRequest::Remove { session }
14712 | NodeRequest::ResolveInteraction { session, .. }
14713 | NodeRequest::SetSessionMode { session, .. }
14714 | NodeRequest::SetSessionConfigOption { session, .. }
14715 | NodeRequest::SetSessionModel { session, .. } => shared
14716 .session_bindings
14717 .lock()
14718 .unwrap_or_else(|poisoned| poisoned.into_inner())
14719 .get(&session.session.instance_id)
14720 .filter(|binding| {
14721 binding.workspace_id == session.workspace_id
14722 && binding.generation == session.session.generation
14723 })
14724 .map_or(true, |binding| binding.context.is_some()),
14725 NodeRequest::RenameSessionRecord { record_id, .. }
14726 | NodeRequest::SetSessionTask { record_id, .. }
14727 | NodeRequest::ResumeSessionRecord { record_id, .. }
14728 | NodeRequest::ForgetSessionRecord { record_id } => shared
14729 .record(record_id)
14730 .map_or(true, |record| record.context.is_some()),
14731 NodeRequest::ArmHarnessMcpReservation { .. }
14732 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14733 | NodeRequest::ActivateHarnessMcpReservation { .. }
14734 | NodeRequest::AbortHarnessMcpReservation { .. }
14735 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14736 | NodeRequest::RejectHarnessMcpCall { .. }
14737 | NodeRequest::Snapshot
14738 | NodeRequest::Resync { .. }
14739 | NodeRequest::BeginDeliveryStage { .. }
14740 | NodeRequest::PutDeliveryBlobChunk { .. }
14741 | NodeRequest::CommitDeliveryStage { .. }
14742 | NodeRequest::AbortDeliveryStage { .. }
14743 | NodeRequest::BrowseHostDirectories { .. }
14744 | NodeRequest::InspectWorkspace { .. }
14745 | NodeRequest::ReadWorkspaceFile { .. }
14746 | NodeRequest::WriteWorkspaceFile { .. }
14747 | NodeRequest::CreateWorkspaceFile { .. }
14748 | NodeRequest::CreateWorkspaceDirectory { .. }
14749 | NodeRequest::ReadGitHistory { .. }
14750 | NodeRequest::ReadGitDiff { .. }
14751 | NodeRequest::AcquireController { .. }
14752 | NodeRequest::ReleaseController
14753 | NodeRequest::Spawn { .. }
14754 | NodeRequest::SpawnWithOptions { .. }
14755 | NodeRequest::SpawnSpec { .. }
14756 | NodeRequest::RegisterWorkspace { .. }
14757 | NodeRequest::CreateStandaloneWorkspace { .. }
14758 | NodeRequest::UnregisterWorkspace { .. }
14759 | NodeRequest::CreateWorktree { .. }
14760 | NodeRequest::RemoveWorktree { .. }
14761 | NodeRequest::SpawnManagedWorktree { .. }
14762 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14763 | NodeRequest::CleanupManagedWorktree { .. }
14764 | NodeRequest::IndexProviderSession { .. }
14765 | NodeRequest::IndexNativeSession { .. }
14766 | NodeRequest::CatalogNativeSessions { .. }
14767 | NodeRequest::PageNativeSessions { .. }
14768 | NodeRequest::PreviewNativeSession { .. }
14769 | NodeRequest::PreviewSessionRecord { .. }
14770 | NodeRequest::DiscoverHistory { .. }
14771 | NodeRequest::LoadHistory { .. }
14772 | NodeRequest::ExportContextPackForSessionRecord { .. }
14773 | NodeRequest::ExportContextPack { .. }
14774 | NodeRequest::ForgetContextPack { .. }
14775 | NodeRequest::ResolveDurableContextPack { .. }
14776 | NodeRequest::ReadContextPack { .. }
14777 | NodeRequest::Shutdown => false,
14778 }
14779}
14780
14781fn request_requires_open_provider_ids_with(
14782 request: &NodeRequest,
14783 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14784 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14785) -> bool {
14786 match request {
14787 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14788 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14789 NodeRequest::CatalogNativeSessions { route, .. }
14790 | NodeRequest::PageNativeSessions { route, .. } => {
14791 !provider_id_is_legacy(&route.provider)
14792 }
14793 NodeRequest::PreviewNativeSession { selection, .. }
14794 | NodeRequest::IndexNativeSession { selection, .. } => {
14795 !provider_id_is_legacy(&selection.route.provider)
14796 }
14797 NodeRequest::SpawnSpec { .. }
14798 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14799 NodeRequest::SpawnManagedWorktree { .. }
14800 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14801 NodeRequest::Resume { session, .. }
14802 | NodeRequest::Prompt { session, .. }
14803 | NodeRequest::Paste { session, .. }
14804 | NodeRequest::Input { session, .. }
14805 | NodeRequest::TerminalBytes { session, .. }
14806 | NodeRequest::TerminalControl { session, .. }
14807 | NodeRequest::Resize { session, .. }
14808 | NodeRequest::Interrupt { session }
14809 | NodeRequest::Stop { session, .. }
14810 | NodeRequest::Remove { session }
14811 | NodeRequest::ResolveInteraction { session, .. }
14812 | NodeRequest::SetSessionMode { session, .. }
14813 | NodeRequest::SetSessionConfigOption { session, .. }
14814 | NodeRequest::SetSessionModel { session, .. }
14815 | NodeRequest::DiscoverHistory { session, .. }
14816 | NodeRequest::LoadHistory { session, .. }
14817 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14818 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14819 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14820 NodeRequest::RenameSessionRecord { record_id, .. }
14821 | NodeRequest::SetSessionTask { record_id, .. }
14822 | NodeRequest::ResumeSessionRecord { record_id, .. }
14823 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14824 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14825 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14826 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14827 NodeRequest::ArmHarnessMcpReservation { .. }
14828 | NodeRequest::ActivateHarnessMcpReservation { .. }
14829 | NodeRequest::AbortHarnessMcpReservation { .. }
14830 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14831 | NodeRequest::RejectHarnessMcpCall { .. }
14832 | NodeRequest::Snapshot
14833 | NodeRequest::Resync { .. }
14834 | NodeRequest::BeginDeliveryStage { .. }
14835 | NodeRequest::PutDeliveryBlobChunk { .. }
14836 | NodeRequest::CommitDeliveryStage { .. }
14837 | NodeRequest::AbortDeliveryStage { .. }
14838 | NodeRequest::BrowseHostDirectories { .. }
14839 | NodeRequest::InspectWorkspace { .. }
14840 | NodeRequest::ReadWorkspaceFile { .. }
14841 | NodeRequest::WriteWorkspaceFile { .. }
14842 | NodeRequest::CreateWorkspaceFile { .. }
14843 | NodeRequest::CreateWorkspaceDirectory { .. }
14844 | NodeRequest::ReadGitHistory { .. }
14845 | NodeRequest::ReadGitDiff { .. }
14846 | NodeRequest::AcquireController { .. }
14847 | NodeRequest::ReleaseController
14848 | NodeRequest::RegisterWorkspace { .. }
14849 | NodeRequest::CreateStandaloneWorkspace { .. }
14850 | NodeRequest::UnregisterWorkspace { .. }
14851 | NodeRequest::CreateWorktree { .. }
14852 | NodeRequest::RemoveWorktree { .. }
14853 | NodeRequest::CleanupManagedWorktree { .. }
14854 | NodeRequest::Shutdown => false,
14855 NodeRequest::ForgetContextPack { .. } => true,
14856 NodeRequest::ResolveDurableContextPack { .. }
14862 | NodeRequest::ReadContextPack { .. } => true,
14863 }
14864}
14865
14866fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14867 snapshot.enabled_providers.retain(provider_id_is_legacy);
14868 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14869 snapshot
14870 .provider_runtime_statuses
14871 .as_slice()
14872 .iter()
14873 .filter(|status| provider_id_is_legacy(status.provider()))
14874 .cloned(),
14875 )
14876 .expect("a filtered provider runtime projection remains valid");
14877 snapshot
14878 .session_records
14879 .retain(|record| provider_id_is_legacy(&record.provider));
14880 for workspace in &mut snapshot.workspaces {
14881 workspace
14882 .sessions
14883 .retain(|session| provider_id_is_legacy(&session.agent_id));
14884 }
14885}
14886
14887fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14888 for record in &mut snapshot.session_records {
14889 record.environment_profile = None;
14890 }
14891 if let Some(profiles) = snapshot
14892 .launch_inventory
14893 .as_mut()
14894 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14895 {
14896 for profile in profiles {
14897 profile.environment_profile = None;
14898 }
14899 }
14900}
14901
14902fn project_event_without_child_environment_profile(
14903 mut envelope: NodeEventEnvelope,
14904) -> NodeEventEnvelope {
14905 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14906 record.environment_profile = None;
14907 }
14908 envelope
14909}
14910
14911fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14912 let contains_environment_profile = match reply.result.as_ref() {
14913 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14914 receipt.environment_profile.is_some()
14915 }
14916 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14917 receipt.spawn.environment_profile.is_some()
14918 }
14919 _ => false,
14920 };
14921 if contains_environment_profile {
14922 reply.result = Err(failure(
14923 NodeFailureCode::UnsupportedCapability,
14924 "child environment profiles were not negotiated",
14925 ));
14926 return;
14927 }
14928 let Ok(response) = reply.result.as_mut() else {
14929 return;
14930 };
14931 match response {
14932 NodeResponse::Snapshot { snapshot, .. } => {
14933 clear_snapshot_child_environment_profiles(snapshot);
14934 }
14935 NodeResponse::Resync { snapshot, events, .. } => {
14936 clear_snapshot_child_environment_profiles(snapshot);
14937 for event in events {
14938 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14939 record.environment_profile = None;
14940 }
14941 }
14942 }
14943 NodeResponse::SessionRecordUpdated { record }
14944 | NodeResponse::ProviderSessionIndexed { record }
14945 | NodeResponse::NativeSessionIndexed { record, .. }
14946 | NodeResponse::SessionRecordResumed { record, .. } => {
14947 record.environment_profile = None;
14948 }
14949 NodeResponse::Armed { .. }
14950 | NodeResponse::Spawned { .. }
14951 | NodeResponse::Activated { .. }
14952 | NodeResponse::Aborted { .. }
14953 | NodeResponse::ReplyChunkAccepted { .. }
14954 | NodeResponse::CallRejected { .. }
14955 | NodeResponse::DeliveryStageBegun { .. }
14956 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14957 | NodeResponse::DeliveryCommitted { .. }
14958 | NodeResponse::DeliveryStageAborted { .. }
14959 | NodeResponse::WorkspaceInspected { .. }
14960 | NodeResponse::HostDirectoriesBrowsed { .. }
14961 | NodeResponse::WorkspaceFileRead { .. }
14962 | NodeResponse::WorkspaceFileWritten { .. }
14963 | NodeResponse::WorkspaceFileCreated { .. }
14964 | NodeResponse::WorkspaceDirectoryCreated { .. }
14965 | NodeResponse::GitHistoryRead { .. }
14966 | NodeResponse::GitDiffRead { .. }
14967 | NodeResponse::Controller { .. }
14968 | NodeResponse::SpawnAccepted { .. }
14969 | NodeResponse::SpawnSpecAccepted { .. }
14970 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14971 | NodeResponse::ManagedWorktreeCleanup { .. }
14972 | NodeResponse::NativeSessionsCataloged { .. }
14973 | NodeResponse::NativeSessionsPaged { .. }
14974 | NodeResponse::NativeSessionPreviewed { .. }
14975 | NodeResponse::SessionRecordPreviewed { .. }
14976 | NodeResponse::HistoryDiscovered { .. }
14977 | NodeResponse::HistoryLoaded { .. }
14978 | NodeResponse::ContextPackForSessionRecordExported { .. }
14979 | NodeResponse::ContextPackExported { .. }
14980 | NodeResponse::ContextPackForgotten { .. }
14981 | NodeResponse::DurableContextPackResolved { .. }
14982 | NodeResponse::ContextPackBytesRead { .. }
14983 | NodeResponse::SessionRecordForgotten { .. }
14984 | NodeResponse::WorkspaceRegistered { .. }
14985 | NodeResponse::StandaloneWorkspaceCreated { .. }
14986 | NodeResponse::WorkspaceUnregistered { .. }
14987 | NodeResponse::WorktreeCreated { .. }
14988 | NodeResponse::WorktreeRemoved { .. }
14989 | NodeResponse::Accepted
14990 | NodeResponse::ShuttingDown => {}
14991 }
14992}
14993
14994fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14995 for record in &mut snapshot.session_records {
14996 record.bundle = None;
14997 }
14998}
14999
15000fn project_event_without_session_bundle(
15001 mut envelope: NodeEventEnvelope,
15002) -> NodeEventEnvelope {
15003 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15004 record.bundle = None;
15005 }
15006 envelope
15007}
15008
15009fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15010 let contains_bundle = match reply.result.as_ref() {
15011 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15012 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15013 receipt.spawn.bundle.is_some()
15014 }
15015 _ => false,
15016 };
15017 if contains_bundle {
15018 reply.result = Err(failure(
15019 NodeFailureCode::UnsupportedCapability,
15020 "session bundles were not negotiated",
15021 ));
15022 return;
15023 }
15024 let Ok(response) = reply.result.as_mut() else {
15025 return;
15026 };
15027 match response {
15028 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15029 NodeResponse::Resync { snapshot, events, .. } => {
15030 clear_snapshot_session_bundles(snapshot);
15031 for event in events {
15032 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15033 record.bundle = None;
15034 }
15035 }
15036 }
15037 NodeResponse::SessionRecordUpdated { record }
15038 | NodeResponse::ProviderSessionIndexed { record }
15039 | NodeResponse::NativeSessionIndexed { record, .. }
15040 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15041 NodeResponse::Armed { .. }
15042 | NodeResponse::Spawned { .. }
15043 | NodeResponse::Activated { .. }
15044 | NodeResponse::Aborted { .. }
15045 | NodeResponse::ReplyChunkAccepted { .. }
15046 | NodeResponse::CallRejected { .. }
15047 | NodeResponse::DeliveryStageBegun { .. }
15048 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15049 | NodeResponse::DeliveryCommitted { .. }
15050 | NodeResponse::DeliveryStageAborted { .. }
15051 | NodeResponse::WorkspaceInspected { .. }
15052 | NodeResponse::HostDirectoriesBrowsed { .. }
15053 | NodeResponse::WorkspaceFileRead { .. }
15054 | NodeResponse::WorkspaceFileWritten { .. }
15055 | NodeResponse::WorkspaceFileCreated { .. }
15056 | NodeResponse::WorkspaceDirectoryCreated { .. }
15057 | NodeResponse::GitHistoryRead { .. }
15058 | NodeResponse::GitDiffRead { .. }
15059 | NodeResponse::Controller { .. }
15060 | NodeResponse::SpawnAccepted { .. }
15061 | NodeResponse::SpawnSpecAccepted { .. }
15062 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15063 | NodeResponse::ManagedWorktreeCleanup { .. }
15064 | NodeResponse::NativeSessionsCataloged { .. }
15065 | NodeResponse::NativeSessionsPaged { .. }
15066 | NodeResponse::NativeSessionPreviewed { .. }
15067 | NodeResponse::SessionRecordPreviewed { .. }
15068 | NodeResponse::HistoryDiscovered { .. }
15069 | NodeResponse::HistoryLoaded { .. }
15070 | NodeResponse::ContextPackForSessionRecordExported { .. }
15071 | NodeResponse::ContextPackExported { .. }
15072 | NodeResponse::ContextPackForgotten { .. }
15073 | NodeResponse::DurableContextPackResolved { .. }
15074 | NodeResponse::ContextPackBytesRead { .. }
15075 | NodeResponse::SessionRecordForgotten { .. }
15076 | NodeResponse::WorkspaceRegistered { .. }
15077 | NodeResponse::StandaloneWorkspaceCreated { .. }
15078 | NodeResponse::WorkspaceUnregistered { .. }
15079 | NodeResponse::WorktreeCreated { .. }
15080 | NodeResponse::WorktreeRemoved { .. }
15081 | NodeResponse::Accepted
15082 | NodeResponse::ShuttingDown => {}
15083 }
15084}
15085
15086fn project_snapshot_history_for_wire(
15087 snapshot: &mut NodeSnapshot,
15088 include_history_metadata: bool,
15089) {
15090 for workspace in &mut snapshot.workspaces {
15091 for session in &mut workspace.sessions {
15092 if include_history_metadata {
15093 session.history.loaded = None;
15094 } else {
15095 session.history = Default::default();
15096 }
15097 }
15098 }
15099}
15100
15101fn project_event_history_for_wire(
15102 mut envelope: NodeEventEnvelope,
15103 include_history_metadata: bool,
15104) -> NodeEventEnvelope {
15105 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15106 for session in &mut workspace.sessions {
15107 if include_history_metadata {
15108 session.history.loaded = None;
15109 } else {
15110 session.history = Default::default();
15111 }
15112 }
15113 }
15114 envelope
15115}
15116
15117fn project_response_history_for_wire(
15118 reply: &mut ResponseEnvelope,
15119 include_history_metadata: bool,
15120) {
15121 let Ok(response) = reply.result.as_mut() else {
15122 return;
15123 };
15124 match response {
15125 NodeResponse::Snapshot { snapshot, .. } => {
15126 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15127 }
15128 NodeResponse::Resync {
15129 snapshot, events, ..
15130 } => {
15131 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15132 for event in events {
15133 *event = project_event_history_for_wire(
15134 event.clone(),
15135 include_history_metadata,
15136 );
15137 }
15138 }
15139 NodeResponse::WorkspaceRegistered { workspace }
15140 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15141 | NodeResponse::WorktreeCreated { workspace, .. } => {
15142 for session in &mut workspace.sessions {
15143 if include_history_metadata {
15144 session.history.loaded = None;
15145 } else {
15146 session.history = Default::default();
15147 }
15148 }
15149 }
15150 _ => {}
15151 }
15152}
15153
15154fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15155 for record in &mut snapshot.session_records {
15156 record.context_id = None;
15157 record.context = None;
15158 record.exported_context = None;
15159 }
15160}
15161
15162fn project_event_without_context_pack(
15163 mut envelope: NodeEventEnvelope,
15164) -> NodeEventEnvelope {
15165 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15166 record.context_id = None;
15167 record.context = None;
15168 record.exported_context = None;
15169 }
15170 envelope
15171}
15172
15173fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15174 let contains_context = match reply.result.as_ref() {
15175 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15176 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15177 receipt.spawn.context.is_some()
15178 }
15179 Ok(NodeResponse::HistoryDiscovered { .. })
15180 | Ok(NodeResponse::HistoryLoaded { .. })
15181 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15182 | Ok(NodeResponse::ContextPackExported { .. })
15183 | Ok(NodeResponse::ContextPackForgotten { .. })
15184 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15185 _ => false,
15186 };
15187 if contains_context {
15188 reply.result = Err(failure(
15189 NodeFailureCode::UnsupportedCapability,
15190 "history context packs were not negotiated",
15191 ));
15192 return;
15193 }
15194 let Ok(response) = reply.result.as_mut() else {
15195 return;
15196 };
15197 match response {
15198 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15199 NodeResponse::Resync {
15200 snapshot, events, ..
15201 } => {
15202 clear_snapshot_context_packs(snapshot);
15203 for event in events {
15204 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15205 record.context_id = None;
15206 record.context = None;
15207 record.exported_context = None;
15208 }
15209 }
15210 }
15211 NodeResponse::SessionRecordUpdated { record }
15212 | NodeResponse::ProviderSessionIndexed { record }
15213 | NodeResponse::NativeSessionIndexed { record, .. }
15214 | NodeResponse::SessionRecordResumed { record, .. } => {
15215 record.context_id = None;
15216 record.context = None;
15217 record.exported_context = None;
15218 }
15219 _ => {}
15220 }
15221}
15222
15223fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15224 let contains_open_record = match reply.result.as_ref() {
15225 Ok(NodeResponse::SessionRecordUpdated { record })
15226 | Ok(NodeResponse::ProviderSessionIndexed { record })
15227 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15228 !provider_id_is_legacy(&record.provider)
15229 }
15230 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15231 !provider_id_is_legacy(&selection.route.provider)
15232 || !provider_id_is_legacy(&record.provider)
15233 }
15234 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15235 !provider_id_is_legacy(&receipt.provider)
15236 }
15237 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15238 !provider_id_is_legacy(&receipt.spawn.provider)
15239 }
15240 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15241 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15242 !provider_id_is_legacy(&route.provider)
15243 }
15244 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15245 !provider_id_is_legacy(&selection.route.provider)
15246 }
15247 _ => false,
15248 };
15249 if contains_open_record {
15250 reply.result = Err(failure(
15251 NodeFailureCode::UnsupportedCapability,
15252 "open provider IDs were not negotiated",
15253 ));
15254 return;
15255 }
15256 let Ok(response) = reply.result.as_mut() else {
15257 return;
15258 };
15259 match response {
15260 NodeResponse::Snapshot { snapshot, .. } => {
15261 project_snapshot_legacy_provider_ids(snapshot);
15262 }
15263 NodeResponse::Resync { snapshot, events, .. } => {
15264 project_snapshot_legacy_provider_ids(snapshot);
15265 *events = std::mem::take(events)
15266 .into_iter()
15267 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15268 .collect();
15269 }
15270 NodeResponse::WorkspaceRegistered { workspace }
15271 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15272 | NodeResponse::WorktreeCreated { workspace, .. } => {
15273 workspace
15274 .sessions
15275 .retain(|session| provider_id_is_legacy(&session.agent_id));
15276 }
15277 NodeResponse::Armed { .. }
15278 | NodeResponse::Spawned { .. }
15279 | NodeResponse::Activated { .. }
15280 | NodeResponse::Aborted { .. }
15281 | NodeResponse::ReplyChunkAccepted { .. }
15282 | NodeResponse::CallRejected { .. }
15283 | NodeResponse::DeliveryStageBegun { .. }
15284 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15285 | NodeResponse::DeliveryCommitted { .. }
15286 | NodeResponse::DeliveryStageAborted { .. }
15287 | NodeResponse::WorkspaceInspected { .. }
15288 | NodeResponse::HostDirectoriesBrowsed { .. }
15289 | NodeResponse::WorkspaceFileRead { .. }
15290 | NodeResponse::WorkspaceFileWritten { .. }
15291 | NodeResponse::WorkspaceFileCreated { .. }
15292 | NodeResponse::WorkspaceDirectoryCreated { .. }
15293 | NodeResponse::GitHistoryRead { .. }
15294 | NodeResponse::GitDiffRead { .. }
15295 | NodeResponse::Controller { .. }
15296 | NodeResponse::SpawnAccepted { .. }
15297 | NodeResponse::SpawnSpecAccepted { .. }
15298 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15299 | NodeResponse::ManagedWorktreeCleanup { .. }
15300 | NodeResponse::NativeSessionsCataloged { .. }
15301 | NodeResponse::NativeSessionsPaged { .. }
15302 | NodeResponse::NativeSessionPreviewed { .. }
15303 | NodeResponse::SessionRecordPreviewed { .. }
15304 | NodeResponse::HistoryDiscovered { .. }
15305 | NodeResponse::HistoryLoaded { .. }
15306 | NodeResponse::ContextPackForSessionRecordExported { .. }
15307 | NodeResponse::ContextPackExported { .. }
15308 | NodeResponse::ContextPackForgotten { .. }
15309 | NodeResponse::DurableContextPackResolved { .. }
15310 | NodeResponse::ContextPackBytesRead { .. }
15311 | NodeResponse::SessionRecordUpdated { .. }
15312 | NodeResponse::ProviderSessionIndexed { .. }
15313 | NodeResponse::NativeSessionIndexed { .. }
15314 | NodeResponse::SessionRecordResumed { .. }
15315 | NodeResponse::SessionRecordForgotten { .. }
15316 | NodeResponse::WorkspaceUnregistered { .. }
15317 | NodeResponse::WorktreeRemoved { .. }
15318 | NodeResponse::Accepted
15319 | NodeResponse::ShuttingDown => {}
15320 }
15321}
15322
15323fn project_event_legacy_provider_ids(
15324 shared: &NodeShared,
15325 mut envelope: NodeEventEnvelope,
15326) -> Option<NodeEventEnvelope> {
15327 let include = match &mut envelope.event {
15328 NodeEvent::HarnessMcpReadCall { .. } => true,
15329 NodeEvent::Control { address, .. }
15330 | NodeEvent::AgentStream { address, .. }
15331 | NodeEvent::TerminalFrame { address, .. } => shared
15332 .validate_address(address)
15333 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15334 NodeEvent::WorkspaceAdded { workspace } => {
15335 workspace
15336 .sessions
15337 .retain(|session| provider_id_is_legacy(&session.agent_id));
15338 true
15339 }
15340 NodeEvent::SessionRecordUpserted { record } => {
15341 provider_id_is_legacy(&record.provider)
15342 }
15343 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15344 NodeEvent::SessionRecordRemoved { .. } => shared
15345 .history
15346 .lock()
15347 .unwrap_or_else(|poisoned| poisoned.into_inner())
15348 .removed_record_providers
15349 .get(&envelope.sequence)
15350 .is_some_and(provider_id_is_legacy),
15351 NodeEvent::ControllerChanged { .. }
15352 | NodeEvent::ManagedWorktreeUpserted { .. }
15353 | NodeEvent::ManagedWorktreeRemoved { .. }
15354 | NodeEvent::WorkspaceRemoved { .. }
15355 | NodeEvent::ResyncRequired { .. } => true,
15356 };
15357 include.then_some(envelope)
15358}
15359
15360fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15361 let Ok(response) = reply.result.as_mut() else {
15362 return;
15363 };
15364 match response {
15365 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15366 snapshot.provider_runtime_statuses.clear();
15367 }
15368 NodeResponse::Armed { .. }
15369 | NodeResponse::Spawned { .. }
15370 | NodeResponse::Activated { .. }
15371 | NodeResponse::Aborted { .. }
15372 | NodeResponse::ReplyChunkAccepted { .. }
15373 | NodeResponse::CallRejected { .. }
15374 | NodeResponse::DeliveryStageBegun { .. }
15375 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15376 | NodeResponse::DeliveryCommitted { .. }
15377 | NodeResponse::DeliveryStageAborted { .. }
15378 | NodeResponse::WorkspaceInspected { .. }
15379 | NodeResponse::HostDirectoriesBrowsed { .. }
15380 | NodeResponse::WorkspaceFileRead { .. }
15381 | NodeResponse::WorkspaceFileWritten { .. }
15382 | NodeResponse::WorkspaceFileCreated { .. }
15383 | NodeResponse::WorkspaceDirectoryCreated { .. }
15384 | NodeResponse::GitHistoryRead { .. }
15385 | NodeResponse::GitDiffRead { .. }
15386 | NodeResponse::Controller { .. }
15387 | NodeResponse::SpawnAccepted { .. }
15388 | NodeResponse::SpawnSpecAccepted { .. }
15389 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15390 | NodeResponse::ManagedWorktreeCleanup { .. }
15391 | NodeResponse::NativeSessionsCataloged { .. }
15392 | NodeResponse::NativeSessionsPaged { .. }
15393 | NodeResponse::NativeSessionPreviewed { .. }
15394 | NodeResponse::SessionRecordPreviewed { .. }
15395 | NodeResponse::HistoryDiscovered { .. }
15396 | NodeResponse::HistoryLoaded { .. }
15397 | NodeResponse::ContextPackForSessionRecordExported { .. }
15398 | NodeResponse::ContextPackExported { .. }
15399 | NodeResponse::ContextPackForgotten { .. }
15400 | NodeResponse::DurableContextPackResolved { .. }
15401 | NodeResponse::ContextPackBytesRead { .. }
15402 | NodeResponse::SessionRecordUpdated { .. }
15403 | NodeResponse::ProviderSessionIndexed { .. }
15404 | NodeResponse::NativeSessionIndexed { .. }
15405 | NodeResponse::SessionRecordResumed { .. }
15406 | NodeResponse::SessionRecordForgotten { .. }
15407 | NodeResponse::WorkspaceRegistered { .. }
15408 | NodeResponse::StandaloneWorkspaceCreated { .. }
15409 | NodeResponse::WorkspaceUnregistered { .. }
15410 | NodeResponse::WorktreeCreated { .. }
15411 | NodeResponse::WorktreeRemoved { .. }
15412 | NodeResponse::Accepted
15413 | NodeResponse::ShuttingDown => {}
15414 }
15415}
15416
15417struct ControllerReleaseGuard {
15418 shared: Arc<NodeShared>,
15419 connection_id: u64,
15420}
15421
15422impl Drop for ControllerReleaseGuard {
15423 fn drop(&mut self) {
15424 self.shared.release_controller(self.connection_id);
15425 }
15426}
15427
15428struct AbortTaskOnDrop(AbortHandle);
15429
15430impl Drop for AbortTaskOnDrop {
15431 fn drop(&mut self) {
15432 self.0.abort();
15433 }
15434}
15435
15436fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15441 std::env::var(name)
15442 .ok()
15443 .and_then(|value| value.parse::<u64>().ok())
15444 .filter(|value| *value > 0)
15445 .unwrap_or(default)
15446 .min(max)
15447}
15448
15449fn workspace_inspection_time_budget_ms() -> u64 {
15450 env_config_bounded(
15451 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15452 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15453 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15454 )
15455}
15456
15457fn workspace_inspection_entry_cap() -> usize {
15458 env_config_bounded(
15459 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15460 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15461 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15462 ) as usize
15463}
15464
15465struct WorkspaceWalkBudget {
15471 deadline: Instant,
15472 entry_cap: usize,
15473 entries_visited: u64,
15474 time_budget_exceeded: bool,
15475 entry_cap_exceeded: bool,
15476}
15477
15478impl WorkspaceWalkBudget {
15479 fn new(deadline: Instant, entry_cap: usize) -> Self {
15480 Self {
15481 deadline,
15482 entry_cap,
15483 entries_visited: 0,
15484 time_budget_exceeded: false,
15485 entry_cap_exceeded: false,
15486 }
15487 }
15488
15489 fn is_exhausted(&self) -> bool {
15490 self.time_budget_exceeded || self.entry_cap_exceeded
15491 }
15492
15493 fn record_visit(&mut self) -> bool {
15496 self.entries_visited += 1;
15497 if self.entries_visited as usize >= self.entry_cap {
15498 self.entry_cap_exceeded = true;
15499 }
15500 if Instant::now() >= self.deadline {
15501 self.time_budget_exceeded = true;
15502 }
15503 self.is_exhausted()
15504 }
15505}
15506
15507fn collect_workspace_entries(
15508 root: &Path,
15509 deadline: Instant,
15510 entry_cap: usize,
15511) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15512 let mut entries = Vec::new();
15513 let mut truncated = false;
15514 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15515 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15516 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15520 (entries, truncated, budget)
15521}
15522
15523fn workspace_inspection_truncation(
15527 walk_budget: &WorkspaceWalkBudget,
15528 git_time_budget_exceeded: bool,
15529 elapsed: Duration,
15530) -> Option<WorkspaceInspectionTruncationV1> {
15531 if !walk_budget.time_budget_exceeded
15532 && !walk_budget.entry_cap_exceeded
15533 && !git_time_budget_exceeded
15534 {
15535 return None;
15536 }
15537 Some(WorkspaceInspectionTruncationV1 {
15538 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15539 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15540 git_time_budget_exceeded,
15541 entries_visited: walk_budget.entries_visited,
15542 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15543 })
15544}
15545
15546fn walk_workspace_directory(
15547 directory: &Path,
15548 relative_directory: Option<&RepositoryPath>,
15549 depth: usize,
15550 entries: &mut Vec<WorkspaceEntry>,
15551 truncated: &mut bool,
15552 budget: &mut WorkspaceWalkBudget,
15553) {
15554 if budget.is_exhausted() {
15555 *truncated = true;
15556 return;
15557 }
15558 let read_dir = match std::fs::read_dir(directory) {
15559 Ok(read_dir) => read_dir,
15560 Err(_) => {
15561 *truncated = true;
15562 return;
15563 }
15564 };
15565 let mut children = Vec::new();
15566 for child in read_dir {
15567 match child {
15568 Ok(child) => children.push(child),
15569 Err(_) => *truncated = true,
15570 }
15571 }
15572 children.sort_by_key(|child| child.file_name());
15573 for child in children {
15574 if budget.record_visit() {
15575 *truncated = true;
15576 return;
15577 }
15578 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15579 *truncated = true;
15580 return;
15581 }
15582 let file_type = match child.file_type() {
15583 Ok(file_type) => file_type,
15584 Err(_) => {
15585 *truncated = true;
15586 continue;
15587 }
15588 };
15589 if file_type.is_symlink() {
15590 continue;
15591 }
15592 let name = match child.file_name().into_string() {
15593 Ok(name) => name,
15594 Err(_) => {
15595 *truncated = true;
15596 continue;
15597 }
15598 };
15599 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15600 continue;
15601 }
15602 let relative_path = match relative_directory {
15603 None => windows_repository_path(name),
15604 Some(parent) => parent
15605 .as_utf8()
15606 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15607 };
15608 let Some(relative_path) = relative_path else {
15609 *truncated = true;
15610 continue;
15611 };
15612 let kind = if file_type.is_dir() {
15613 WorkspaceEntryKind::Directory
15614 } else if file_type.is_file() {
15615 WorkspaceEntryKind::File
15616 } else {
15617 continue;
15618 };
15619 entries.push(WorkspaceEntry {
15620 relative_path: relative_path.clone(),
15621 kind,
15622 });
15623 if file_type.is_dir() {
15624 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15625 walk_workspace_directory(
15626 &child.path(),
15627 Some(&relative_path),
15628 depth + 1,
15629 entries,
15630 truncated,
15631 budget,
15632 );
15633 } else {
15634 *truncated = true;
15635 }
15636 }
15637 }
15638}
15639
15640fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15641 if value.contains('\\') {
15642 return None;
15643 }
15644 RepositoryPath::utf8(value).ok()
15645}
15646
15647fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15648 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15649}
15650
15651fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15660 if name.eq_ignore_ascii_case(".git")
15661 || name.eq_ignore_ascii_case("target")
15662 || name.eq_ignore_ascii_case("node_modules")
15663 {
15664 return true;
15665 }
15666 path.join("CACHEDIR.TAG").is_file()
15667}
15668
15669fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15670 matches!(
15671 status,
15672 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15673 )
15674}
15675
15676#[derive(Clone, Debug, Eq, PartialEq)]
15677enum ContextPackRepositoryHead {
15678 NotRepository,
15679 Unborn,
15680 Commit(String),
15681}
15682
15683#[derive(Clone, Debug, Eq, PartialEq)]
15684struct ContextPackRepositoryObservation {
15685 head: ContextPackRepositoryHead,
15686 git: GitSnapshot,
15687 files: Vec<ContextPackRepositoryFileSource>,
15688}
15689
15690fn stable_context_pack_repository(
15691 first: ContextPackRepositoryObservation,
15692 second: ContextPackRepositoryObservation,
15693) -> Result<ContextPackRepository, NodeFailure> {
15694 if first != second {
15695 return Err(failure(
15696 NodeFailureCode::ContextPackMaterializationFailed,
15697 "context repository changed during bounded capture",
15698 ));
15699 }
15700 Ok(ContextPackRepository::from_sources(
15701 &second.git,
15702 second.files,
15703 ))
15704}
15705
15706fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15713 if Instant::now() < deadline {
15714 return false;
15715 }
15716 append_git_diagnostic(
15717 snapshot,
15718 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15719 );
15720 true
15721}
15722
15723async fn run_git_bounded_with_deadline(
15732 root: &str,
15733 arguments: &[&str],
15734 output_limit: usize,
15735 deadline: Instant,
15736) -> io::Result<GitCommandOutput> {
15737 let remaining = deadline.checked_duration_since(Instant::now());
15738 let timeout_ms = remaining
15739 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15740 .unwrap_or(0)
15741 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15742 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15743}
15744
15745async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15752 let mut snapshot = GitSnapshot {
15753 is_repository: false,
15754 branch: None,
15755 status: Vec::new(),
15756 recent_commits: Vec::new(),
15757 worktrees: Vec::new(),
15758 managed_worktree: None,
15759 truncated: false,
15760 diagnostic: None,
15761 };
15762 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15763 return (snapshot, true);
15764 }
15765 let repository = match run_git_bounded_with_deadline(
15766 root,
15767 &["rev-parse", "--is-inside-work-tree"],
15768 4 * 1_024,
15769 deadline,
15770 )
15771 .await
15772 {
15773 Ok(output) => output,
15774 Err(error) => {
15775 append_git_diagnostic(
15776 &mut snapshot,
15777 &format!("git inspection unavailable: {error}"),
15778 );
15779 return (snapshot, false);
15780 }
15781 };
15782 snapshot.truncated |= repository.truncated;
15783 if repository.timed_out {
15784 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15785 return (snapshot, false);
15786 }
15787 if !repository.success
15788 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15789 {
15790 let stderr = String::from_utf8_lossy(&repository.stderr);
15791 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15792 append_git_diagnostic(&mut snapshot, stderr.trim());
15793 }
15794 return (snapshot, false);
15795 }
15796 snapshot.is_repository = true;
15797
15798 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15799 return (snapshot, true);
15800 }
15801 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15802 Ok(output) => {
15803 snapshot.truncated |= output.truncated;
15804 if output.timed_out {
15805 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15806 } else if output.success {
15807 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15808 if !branch.is_empty() {
15809 snapshot.branch = Some(truncate_git_field(
15810 &branch,
15811 MAX_REPOSITORY_PATH_BYTES,
15812 &mut snapshot.truncated,
15813 ));
15814 }
15815 }
15816 }
15817 Err(error) => append_git_diagnostic(
15818 &mut snapshot,
15819 &format!("git branch query failed: {error}"),
15820 ),
15821 }
15822 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15823 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15824 snapshot.truncated |= output.truncated;
15825 if output.success && !output.timed_out {
15826 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15827 if !head.is_empty() {
15828 snapshot.branch = Some(format!("detached:{head}"));
15829 }
15830 }
15831 }
15832 }
15833
15834 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15835 return (snapshot, true);
15836 }
15837 match run_git_bounded_with_deadline(
15838 root,
15839 &[
15840 "status",
15841 "--porcelain=v1",
15842 "-z",
15843 "--renames",
15844 "--untracked-files=normal",
15845 "--",
15846 ".",
15847 ],
15848 GIT_OUTPUT_MAX_BYTES,
15849 deadline,
15850 )
15851 .await
15852 {
15853 Ok(output) => {
15854 snapshot.truncated |= output.truncated;
15855 if output.timed_out {
15856 append_git_diagnostic(&mut snapshot, "git status timed out");
15857 } else if output.success {
15858 parse_git_status(&output.stdout, &mut snapshot);
15859 } else {
15860 append_git_diagnostic(
15861 &mut snapshot,
15862 String::from_utf8_lossy(&output.stderr).trim(),
15863 );
15864 }
15865 }
15866 Err(error) => append_git_diagnostic(
15867 &mut snapshot,
15868 &format!("git status failed: {error}"),
15869 ),
15870 }
15871
15872 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15873 return (snapshot, true);
15874 }
15875 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15876 match run_git_bounded_with_deadline(
15877 root,
15878 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15879 16 * 1_024,
15880 deadline,
15881 )
15882 .await
15883 {
15884 Ok(output) => {
15885 snapshot.truncated |= output.truncated;
15886 if output.timed_out {
15887 append_git_diagnostic(&mut snapshot, "git log timed out");
15888 } else if output.success {
15889 parse_git_commits(&output.stdout, &mut snapshot);
15890 } else {
15891 let stderr = String::from_utf8_lossy(&output.stderr);
15892 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15893 append_git_diagnostic(&mut snapshot, stderr.trim());
15894 }
15895 }
15896 }
15897 Err(error) => append_git_diagnostic(
15898 &mut snapshot,
15899 &format!("git log failed: {error}"),
15900 ),
15901 }
15902
15903 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15904 return (snapshot, true);
15905 }
15906 match list_git_worktrees_with_deadline(root, deadline).await {
15907 Ok(worktrees) => {
15908 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15909 }
15910 Err(error) => append_git_diagnostic(
15911 &mut snapshot,
15912 &format!("git worktree list failed: {}", error.message),
15913 ),
15914 }
15915 (snapshot, false)
15916}
15917
15918fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15919 let mut remaining = output;
15920 while !remaining.is_empty() {
15921 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15922 snapshot.truncated = true;
15923 break;
15924 }
15925 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15926 snapshot.truncated = true;
15927 break;
15928 };
15929 let record = &remaining[..record_end];
15930 remaining = &remaining[record_end + 1..];
15931 let has_previous_path = record
15932 .get(..2)
15933 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15934 let previous_path_bytes = if has_previous_path {
15935 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15936 snapshot.truncated = true;
15937 break;
15938 };
15939 let previous = &remaining[..previous_end];
15940 remaining = &remaining[previous_end + 1..];
15941 Some(previous)
15942 } else {
15943 None
15944 };
15945 if record.len() < 4
15946 || record[2] != b' '
15947 || !is_git_status_code(record[0])
15948 || !is_git_status_code(record[1])
15949 {
15950 snapshot.truncated = true;
15951 continue;
15952 }
15953 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15954 snapshot.truncated = true;
15955 continue;
15956 };
15957 let previous_path = match previous_path_bytes {
15958 Some(previous) => {
15959 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15960 snapshot.truncated = true;
15961 continue;
15962 };
15963 Some(previous)
15964 }
15965 None => None,
15966 };
15967 snapshot.status.push(GitStatusEntry {
15968 index_status: (record[0] as char).to_string(),
15969 worktree_status: (record[1] as char).to_string(),
15970 path,
15971 previous_path,
15972 });
15973 }
15974}
15975
15976fn is_git_status_code(byte: u8) -> bool {
15977 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15978}
15979
15980fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15981 for line in String::from_utf8_lossy(output).lines() {
15982 let Some((id, summary)) = line.split_once('\u{1f}') else {
15983 continue;
15984 };
15985 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15986 snapshot.truncated = true;
15987 break;
15988 }
15989 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15990 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15991 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15992 }
15993}
15994
15995fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15996 if value.len() <= max_bytes {
15997 return value.to_owned();
15998 }
15999 *truncated = true;
16000 let mut boundary = max_bytes;
16001 while boundary > 0 && !value.is_char_boundary(boundary) {
16002 boundary -= 1;
16003 }
16004 value[..boundary].to_owned()
16005}
16006
16007fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16008 if message.is_empty() {
16009 return;
16010 }
16011 let normalized = message
16012 .chars()
16013 .map(|character| if character.is_control() { ' ' } else { character })
16014 .collect::<String>();
16015 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16016 if !diagnostic.is_empty() {
16017 diagnostic.push_str("; ");
16018 }
16019 diagnostic.push_str(normalized.trim());
16020 snapshot.diagnostic = Some(truncate_git_field(
16021 &diagnostic,
16022 GIT_DIAGNOSTIC_MAX_BYTES,
16023 &mut snapshot.truncated,
16024 ));
16025}
16026
16027async fn run_git_bounded(
16028 root: &str,
16029 arguments: &[&str],
16030 output_limit: usize,
16031) -> io::Result<GitCommandOutput> {
16032 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16033}
16034
16035async fn read_git_history_bounded(
16036 root: &str,
16037 path: Option<&RepositoryPath>,
16038 before: Option<&GitObjectId>,
16039 limit: u16,
16040) -> Result<GitHistoryPage, NodeFailure> {
16041 let path = path
16042 .map(|path| {
16043 path.as_utf8().ok_or_else(|| {
16044 failure(
16045 NodeFailureCode::InvalidRepositoryPath,
16046 "git history path must use UTF-8",
16047 )
16048 })
16049 })
16050 .transpose()?
16051 .unwrap_or(".");
16052 let count = (usize::from(limit) + 1).to_string();
16053 let start = before
16054 .map(|revision| format!("{}^", revision.as_str()))
16055 .unwrap_or_else(|| "HEAD".to_owned());
16056 let arguments = [
16057 "--no-pager",
16058 "log",
16059 "--no-decorate",
16060 "--date=iso-strict",
16061 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16062 "-n",
16063 count.as_str(),
16064 start.as_str(),
16065 "--",
16066 path,
16067 ];
16068 let output = run_git_bounded(root, &arguments, 256 * 1_024)
16069 .await
16070 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16071 if output.timed_out {
16072 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16073 }
16074 if !output.success {
16075 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16076 }
16077 let mut commits = parse_git_history(&output.stdout);
16078 let has_more = commits.len() > usize::from(limit) || output.truncated;
16079 commits.truncate(usize::from(limit));
16080 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16081 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16082}
16083
16084fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16085 String::from_utf8_lossy(output)
16086 .split('\u{1e}')
16087 .filter_map(|record| {
16088 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16089 if fields.len() != 9 {
16090 return None;
16091 }
16092 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16093 let parents = if fields[1].is_empty() {
16094 Vec::new()
16095 } else {
16096 fields[1]
16097 .split(' ')
16098 .map(|value| GitObjectId::new(value.to_owned()))
16099 .collect::<Result<Vec<_>, _>>()
16100 .ok()?
16101 };
16102 Some(GitCommitDetails {
16103 id,
16104 parents,
16105 subject: bounded_git_text(fields[2], 512),
16106 author_name: bounded_git_text(fields[3], 256),
16107 author_email: bounded_git_text(fields[4], 320),
16108 authored_at: bounded_git_text(fields[5], 64),
16109 committer_name: bounded_git_text(fields[6], 256),
16110 committer_email: bounded_git_text(fields[7], 320),
16111 committed_at: bounded_git_text(fields[8], 64),
16112 signature_status: GitSignatureStatus::NoSignature,
16113 signer: None,
16114 })
16115 })
16116 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16117 .collect()
16118}
16119
16120fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16121 let normalized = value
16122 .chars()
16123 .map(|character| if character.is_control() { ' ' } else { character })
16124 .collect::<String>();
16125 if normalized.len() <= max_bytes {
16126 return normalized;
16127 }
16128 let mut boundary = max_bytes;
16129 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16130 boundary -= 1;
16131 }
16132 normalized[..boundary].to_owned()
16133}
16134
16135async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16136 let path = request.path.as_ref().map(|path| {
16137 path.as_utf8()
16138 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16139 }).transpose()?;
16140 let mut arguments = match &request.mode {
16141 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16142 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16143 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16144 };
16145 if let GitDiffMode::Commit { revision } = &request.mode {
16146 arguments.push(revision.as_str());
16147 }
16148 arguments.push("--");
16149 if let Some(path) = path {
16150 arguments.push(path);
16151 }
16152 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16153 .await
16154 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16155 if output.timed_out {
16156 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16157 }
16158 if !output.success {
16159 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16160 }
16161 Ok(GitDiff {
16162 mode: request.mode,
16163 path: request.path,
16164 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16165 truncated: output.truncated,
16166 })
16167}
16168
16169async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16170 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16171 ResponseEnvelope { request_id: envelope.request_id, result }
16172}
16173
16174async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16175 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16176 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16177 }
16178 let read_only = matches!(
16179 &request,
16180 NodeRequest::Snapshot
16181 | NodeRequest::Resync { .. }
16182 | NodeRequest::BrowseHostDirectories { .. }
16183 | NodeRequest::InspectWorkspace { .. }
16184 | NodeRequest::ReadWorkspaceFile { .. }
16185 | NodeRequest::ReadGitHistory { .. }
16186 | NodeRequest::ReadGitDiff { .. }
16187 | NodeRequest::CatalogNativeSessions { .. }
16188 | NodeRequest::PageNativeSessions { .. }
16189 | NodeRequest::PreviewNativeSession { .. }
16190 | NodeRequest::PreviewSessionRecord { .. }
16191 );
16192 let _mutation_guard = if read_only {
16193 None
16194 } else {
16195 let guard = shared.mutation_gate.lock().await;
16196 if shared.shutdown.load(Ordering::Acquire) {
16197 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16198 }
16199 Some(guard)
16200 };
16201 match request {
16202 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16203 event_sequence: shared.current_sequence(),
16204 controller: shared.controller_state(),
16205 snapshot: shared.snapshot(),
16206 }),
16207 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16208 NodeRequest::ArmHarnessMcpReservation {
16209 reservation_id,
16210 activation_digest,
16211 spawn_spec,
16212 launch,
16213 expires_at_unix_ms,
16214 } => {
16215 shared.require_controller(connection_id, role)?;
16216 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16217 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16218 })?;
16219 let expires_at_unix_ms = registry
16220 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16221 .await
16222 .map_err(harness_mcp_failure)?;
16223 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16224 }
16225 NodeRequest::SpawnSpecWithHarnessMcp {
16226 reservation_id,
16227 activation_digest,
16228 spec,
16229 deadline_unix_ms,
16230 } => {
16231 shared.require_controller(connection_id, role)?;
16232 let receipt = shared
16233 .spawn_from_spec_with_harness_mcp(
16234 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16235 )
16236 .await?;
16237 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16238 }
16239 NodeRequest::ActivateHarnessMcpReservation {
16240 reservation_id,
16241 activation_digest,
16242 record_id,
16243 session,
16244 } => {
16245 shared.require_controller(connection_id, role)?;
16246 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16247 current.instance_id == session.session.instance_id
16248 && current.generation == session.session.generation
16249 }).and_then(|current| current.process_id).ok_or_else(|| {
16250 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16251 })?;
16252 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16253 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16254 })?.activate(
16255 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16256 ).map_err(harness_mcp_failure)?;
16257 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16258 }
16259 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16260 shared.require_controller(connection_id, role)?;
16261 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16262 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16263 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16264 if let Some(instance_id) = instance_id {
16265 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16266 }
16267 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16268 }
16269 NodeRequest::PutHarnessMcpReplyChunk {
16270 reservation_id, activation_digest, record_id, session, call_id,
16271 offset, final_chunk, chunk_hex,
16272 } => {
16273 shared.require_controller(connection_id, role)?;
16274 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16275 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16276 })?.put_reply_chunk(
16277 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16278 offset, final_chunk, &chunk_hex,
16279 ).map_err(harness_mcp_failure)?;
16280 Ok(NodeResponse::ReplyChunkAccepted {
16281 reservation_id, activation_digest, record_id, session, call_id,
16282 next_offset, completed,
16283 })
16284 }
16285 NodeRequest::RejectHarnessMcpCall {
16286 reservation_id, activation_digest, record_id, session, call_id, reason,
16287 } => {
16288 shared.require_controller(connection_id, role)?;
16289 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16290 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16291 })?.reject_call(
16292 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16293 ).map_err(harness_mcp_failure)?;
16294 Ok(NodeResponse::CallRejected {
16295 reservation_id, activation_digest, record_id, session, call_id,
16296 })
16297 }
16298 NodeRequest::BeginDeliveryStage { manifest } => {
16299 shared.require_controller(connection_id, role)?;
16300 shared.begin_delivery_stage(manifest)
16301 }
16302 NodeRequest::PutDeliveryBlobChunk {
16303 stage_id,
16304 blob_digest,
16305 offset,
16306 chunk_hex,
16307 } => {
16308 shared.require_controller(connection_id, role)?;
16309 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16310 }
16311 NodeRequest::CommitDeliveryStage { stage_id } => {
16312 shared.require_controller(connection_id, role)?;
16313 let receipt = shared.commit_delivery_stage(stage_id)?;
16314 Ok(NodeResponse::DeliveryCommitted { receipt })
16315 }
16316 NodeRequest::AbortDeliveryStage { stage_id } => {
16317 shared.require_controller(connection_id, role)?;
16318 shared.abort_delivery_stage(&stage_id)?;
16319 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16320 }
16321 NodeRequest::BrowseHostDirectories { directory, after } => {
16322 let listing = shared.browse_host_directories(directory, after).await?;
16323 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16324 }
16325 NodeRequest::InspectWorkspace { workspace_id } => {
16326 let inspection = shared.inspect_workspace(workspace_id).await?;
16327 Ok(NodeResponse::WorkspaceInspected { inspection })
16328 }
16329 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16330 let file = shared.read_workspace_file(workspace_id, path).await?;
16331 Ok(NodeResponse::WorkspaceFileRead { file })
16332 }
16333 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16334 shared.require_controller(connection_id, role)?;
16335 let file = shared
16336 .write_workspace_file(workspace_id, path, expected_revision, text)
16337 .await?;
16338 Ok(NodeResponse::WorkspaceFileWritten { file })
16339 }
16340 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16341 shared.require_controller(connection_id, role)?;
16342 let file = shared.create_workspace_file(workspace_id, path).await?;
16343 Ok(NodeResponse::WorkspaceFileCreated { file })
16344 }
16345 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16346 shared.require_controller(connection_id, role)?;
16347 let entry = shared
16348 .create_workspace_directory(workspace_id.clone(), path)
16349 .await?;
16350 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16351 }
16352 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16353 if role != ClientRole::Operator {
16354 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16355 }
16356 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16357 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16358 }
16359 let page = shared
16360 .read_git_history(workspace_id.clone(), path, before, limit)
16361 .await?;
16362 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16363 }
16364 NodeRequest::ReadGitDiff { workspace_id, request } => {
16365 if role != ClientRole::Operator {
16366 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16367 }
16368 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16369 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16370 }
16371 NodeRequest::CatalogNativeSessions { route, limit } => {
16372 if role != ClientRole::Operator {
16373 return Err(failure(
16374 NodeFailureCode::ObserverReadOnly,
16375 "native session catalog requires an operator connection",
16376 ));
16377 }
16378 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16379 return Err(failure(
16380 NodeFailureCode::InvalidRequest,
16381 "native session catalog limit is invalid",
16382 ));
16383 }
16384 let (entries, summary) = shared
16385 .catalog_native_sessions(route.clone(), limit)
16386 .await?;
16387 Ok(NodeResponse::NativeSessionsCataloged {
16388 route,
16389 entries,
16390 summary: Some(summary),
16391 })
16392 }
16393 NodeRequest::PageNativeSessions {
16394 route,
16395 window,
16396 catalog_revision,
16397 recent_cutoff_unix_ms,
16398 after_selection_id,
16399 limit,
16400 } => {
16401 if role != ClientRole::Operator {
16402 return Err(failure(
16403 NodeFailureCode::ObserverReadOnly,
16404 "native session catalog paging requires an operator connection",
16405 ));
16406 }
16407 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16408 || after_selection_id
16409 .as_deref()
16410 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16411 {
16412 return Err(failure(
16413 NodeFailureCode::InvalidRequest,
16414 "native session catalog page request is invalid",
16415 ));
16416 }
16417 let page = shared
16418 .page_native_sessions(
16419 route.clone(),
16420 window,
16421 catalog_revision,
16422 recent_cutoff_unix_ms,
16423 after_selection_id,
16424 limit,
16425 )
16426 .await?;
16427 Ok(NodeResponse::NativeSessionsPaged {
16428 route,
16429 page,
16430 })
16431 }
16432 NodeRequest::PreviewNativeSession {
16433 selection,
16434 message_limit,
16435 } => {
16436 if role != ClientRole::Operator {
16437 return Err(failure(
16438 NodeFailureCode::ObserverReadOnly,
16439 "native session preview requires an operator connection",
16440 ));
16441 }
16442 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16443 .contains(&message_limit)
16444 || selection.validate().is_err()
16445 {
16446 return Err(failure(
16447 NodeFailureCode::InvalidRequest,
16448 "native session preview request is invalid",
16449 ));
16450 }
16451 let preview = shared
16452 .preview_native_session(selection.clone(), message_limit)
16453 .await?;
16454 Ok(NodeResponse::NativeSessionPreviewed {
16455 selection,
16456 preview,
16457 })
16458 }
16459 NodeRequest::IndexNativeSession {
16460 selection,
16461 display_name,
16462 } => {
16463 shared.require_controller(connection_id, role)?;
16464 let record = shared
16465 .index_native_session(selection.clone(), display_name)
16466 .await?;
16467 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16468 }
16469 NodeRequest::PreviewSessionRecord {
16470 record_id,
16471 message_limit,
16472 } => {
16473 if role != ClientRole::Operator {
16474 return Err(failure(
16475 NodeFailureCode::ObserverReadOnly,
16476 "native session preview requires an operator connection",
16477 ));
16478 }
16479 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16480 .contains(&message_limit)
16481 {
16482 return Err(failure(
16483 NodeFailureCode::InvalidRequest,
16484 "native session preview request is invalid",
16485 ));
16486 }
16487 let record = shared.record(&record_id)?;
16488 let working_directory = shared.workspace_root(&record.workspace_id)?;
16489 if !platform::roots_equal(
16490 &working_directory,
16491 windows_path_text(&record.canonical_root),
16492 ) {
16493 return Err(failure(
16494 NodeFailureCode::SessionRecordConflict,
16495 "managed session workspace root changed; refusing to preview another directory",
16496 ));
16497 }
16498 let identity = record.provider_session.clone().ok_or_else(|| {
16499 failure(
16500 NodeFailureCode::BackendOperationFailed,
16501 "native session preview is unavailable for this session record",
16502 )
16503 })?;
16504 if identity.key != ProviderSessionKey::SessionId {
16505 return Err(failure(
16506 NodeFailureCode::BackendOperationFailed,
16507 "native session preview is unavailable for this session identity",
16508 ));
16509 }
16510 let preview = shared
16511 .preview_native_session_by_session_id(
16512 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16513 )
16514 .await?;
16515 if preview.session_id != identity.id {
16516 return Err(failure(
16517 NodeFailureCode::SessionRecordConflict,
16518 "native session preview identity changed while loading",
16519 ));
16520 }
16521 shared.revalidate_session_record_preview(&record, &identity)?;
16522 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16523 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16524 record_id: record_id.clone(),
16525 summary: SessionHistorySummaryV1::from(&preview),
16526 });
16527 Ok(NodeResponse::SessionRecordPreviewed {
16528 record_id,
16529 preview,
16530 })
16531 }
16532 NodeRequest::AcquireController { lease_ms } => {
16533 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16534 Ok(NodeResponse::Controller { controller: Some(controller) })
16535 }
16536 NodeRequest::ReleaseController => {
16537 shared.release_controller(connection_id);
16538 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16539 }
16540 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16541 shared.require_controller(connection_id, role)?;
16542 let root = require_windows_path(root)?;
16543 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16544 let workspace = shared.register_workspace(workspace_id, root).await?;
16545 Ok(NodeResponse::WorkspaceRegistered { workspace })
16546 }
16547 NodeRequest::CreateStandaloneWorkspace {
16548 workspace_id,
16549 root,
16550 initial_branch,
16551 } => {
16552 shared.require_controller(connection_id, role)?;
16553 let root = require_windows_path(root)?;
16554 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16555 let workspace = shared
16556 .create_standalone_workspace(workspace_id, root, initial_branch)
16557 .await?;
16558 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16559 }
16560 NodeRequest::UnregisterWorkspace { workspace_id } => {
16561 shared.require_controller(connection_id, role)?;
16562 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16563 shared.unregister_workspace(&workspace_id)?;
16564 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16565 }
16566 NodeRequest::CreateWorktree {
16567 source_workspace_id,
16568 workspace_id,
16569 target_root,
16570 branch,
16571 base,
16572 } => {
16573 shared.require_controller(connection_id, role)?;
16574 let target_root = require_windows_path(target_root)?;
16575 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16576 let (worktree, workspace) = shared
16577 .create_worktree(
16578 source_workspace_id,
16579 workspace_id,
16580 target_root,
16581 branch,
16582 base,
16583 )
16584 .await?;
16585 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16586 }
16587 NodeRequest::RemoveWorktree {
16588 source_workspace_id,
16589 target_root,
16590 } => {
16591 shared.require_controller(connection_id, role)?;
16592 let native_target_root = require_windows_path(target_root.clone())?;
16593 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16594 let workspace_id = shared
16595 .remove_worktree(source_workspace_id, native_target_root)
16596 .await?;
16597 Ok(NodeResponse::WorktreeRemoved {
16598 target_root,
16599 workspace_id,
16600 })
16601 }
16602 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16603 shared.require_controller(connection_id, role)?;
16604 if !terminal_size.is_valid() {
16605 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16606 }
16607 if let Some(prompt) = initial_prompt.as_deref() {
16608 validate_node_text("spawn initial prompt", prompt)?;
16609 }
16610 match shared
16611 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16612 .await
16613 {
16614 Ok(session) => {
16615 shared.mark_spawn_provisional(session.clone());
16616 Ok(NodeResponse::SpawnAccepted { session })
16617 }
16618 Err(err) => {
16619 tracing::warn!(
16620 %provider,
16621 %workspace_id,
16622 ?mode,
16623 code = ?err.code,
16624 message = %err.message,
16625 "session spawn refused"
16626 );
16627 Err(err)
16628 }
16629 }
16630 }
16631 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16632 shared.require_controller(connection_id, role)?;
16633 if !terminal_size.is_valid() {
16634 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16635 }
16636 if let Some(prompt) = initial_prompt.as_deref() {
16637 validate_node_text("spawn initial prompt", prompt)?;
16638 }
16639 let plan = backend_paths::plan_launch_options(
16640 provider.as_str(),
16641 mode,
16642 options.model.is_some(),
16643 options.effort.is_some(),
16644 )
16645 .map_err(|r| {
16646 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16647 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16648 })?;
16649 let selection = spawn_selection(&options)?;
16650 let pty_selection = match plan {
16651 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16652 _ => None,
16653 };
16654 let spawned = SPAWN_SESSION_OPTIONS
16655 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16656 .await;
16657 match spawned {
16658 Ok(session) => {
16659 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16660 shared
16661 .pending_acp_options
16662 .lock()
16663 .unwrap_or_else(|p| p.into_inner())
16664 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16665 }
16666 shared.mark_spawn_provisional(session.clone());
16667 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16668 Ok(NodeResponse::SpawnAccepted { session })
16669 }
16670 Err(err) => {
16671 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16672 Err(err)
16673 }
16674 }
16675 }
16676 NodeRequest::SpawnSpec { spec } => {
16677 shared.require_controller(connection_id, role)?;
16678 let receipt = shared.spawn_from_spec(spec).await?;
16679 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16680 }
16681 NodeRequest::SpawnManagedWorktree { request } => {
16682 shared.require_controller(connection_id, role)?;
16683 let receipt = shared.spawn_managed_worktree(request).await?;
16684 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16685 }
16686 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16687 shared.require_controller(connection_id, role)?;
16688 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16689 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16690 }
16691 NodeRequest::CleanupManagedWorktree { lease_id } => {
16692 shared.require_controller(connection_id, role)?;
16693 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16694 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16695 }
16696 NodeRequest::DiscoverHistory { session, limit } => {
16697 controlled_session(shared, connection_id, role, &session)?;
16698 let candidates = shared.discover_history(&session, limit).await?;
16699 Ok(NodeResponse::HistoryDiscovered {
16700 session,
16701 candidates,
16702 })
16703 }
16704 NodeRequest::LoadHistory {
16705 session,
16706 candidate_id,
16707 } => {
16708 controlled_session(shared, connection_id, role, &session)?;
16709 let loaded = shared
16710 .load_history(&session, candidate_id)
16711 .await?;
16712 Ok(NodeResponse::HistoryLoaded {
16713 session,
16714 session_id: loaded.session_id,
16715 message_count: loaded.message_count,
16716 completed_turn_count: loaded.completed_turn_count,
16717 })
16718 }
16719 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16720 shared.require_controller(connection_id, role)?;
16721 let context = shared
16722 .export_context_pack_for_session_record(&record_id, &session, false)
16723 .await?;
16724 Ok(NodeResponse::ContextPackForSessionRecordExported {
16725 record_id,
16726 session,
16727 context,
16728 })
16729 }
16730 NodeRequest::ExportContextPack { session } => {
16731 controlled_session(shared, connection_id, role, &session)?;
16732 let context = shared.export_context_pack(&session).await?;
16733 Ok(NodeResponse::ContextPackExported { context })
16734 }
16735 NodeRequest::ForgetContextPack { context_id } => {
16736 shared.require_controller(connection_id, role)?;
16737 shared.forget_context_pack(&context_id)?;
16738 Ok(NodeResponse::ContextPackForgotten { context_id })
16739 }
16740 NodeRequest::ResolveDurableContextPack { context_id } => {
16741 let context = shared
16745 .context_catalog
16746 .read()
16747 .unwrap_or_else(|poisoned| poisoned.into_inner())
16748 .get(&context_id)
16749 .map(|pack| pack.receipt().clone())
16750 .ok_or_else(|| {
16751 failure(
16752 NodeFailureCode::UnknownContextPack,
16753 "context pack is not present",
16754 )
16755 })?;
16756 Ok(NodeResponse::DurableContextPackResolved { context })
16757 }
16758 NodeRequest::ReadContextPack { digest } => {
16759 let (id, byte_len, bytes) = shared
16764 .context_catalog
16765 .read()
16766 .unwrap_or_else(|poisoned| poisoned.into_inner())
16767 .get_by_digest(&digest)
16768 .map(|pack| {
16769 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16770 })
16771 .ok_or_else(|| {
16772 failure(
16773 NodeFailureCode::UnknownContextPack,
16774 &format!("context pack {digest} not on this node"),
16775 )
16776 })?;
16777 tracing::debug!(
16778 digest = digest.as_str(),
16779 byte_len,
16780 "read context pack bytes",
16781 );
16782 Ok(NodeResponse::ContextPackBytesRead {
16783 pack: ContextPackBytesRead {
16784 digest,
16785 id,
16786 byte_len,
16787 bytes,
16788 },
16789 })
16790 }
16791 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16792 let provider = controlled_session(shared, connection_id, role, &session)?;
16793 if let Some(prompt) = initial_prompt.as_deref() {
16794 validate_node_text("resume initial prompt", prompt)?;
16795 }
16796 let runtime_requirement = if initial_prompt.is_some() {
16797 ProviderRuntimeRequirement::ResumeWithPrompt
16798 } else {
16799 ProviderRuntimeRequirement::Resume
16800 };
16801 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16802 let runtime_policy = shared
16803 .admit_provider_runtime(&provider, runtime_requirement)
16804 .await?;
16805 let working_directory = shared.bound_workspace_root(&session)?;
16806 let request = ResumeLaunchRequest {
16807 working_directory,
16808 terminal_size,
16809 initial_prompt,
16810 };
16811 request
16812 .validate()
16813 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16814 let command = shared.prepare_command(ControlCommand::Resume {
16815 instance_id: session.session.instance_id,
16816 target: ResumeTarget::CurrentProvider,
16817 runtime_policy,
16818 request,
16819 });
16820 shared.arm_resume(&session, command.id, runtime_policy)?;
16821 let dispatch = shared.dispatch_envelope(command);
16822 if let Err(error) = dispatch {
16823 shared.clear_armed_resume(&session);
16824 return Err(error);
16825 }
16826 Ok(NodeResponse::Accepted)
16827 }
16828 NodeRequest::RenameSessionRecord {
16829 record_id,
16830 display_name,
16831 } => {
16832 shared.require_controller(connection_id, role)?;
16833 let record = shared.rename_session_record(&record_id, display_name)?;
16834 Ok(NodeResponse::SessionRecordUpdated { record })
16835 }
16836 NodeRequest::SetSessionTask {
16837 record_id,
16838 expected_revision,
16839 target,
16840 } => {
16841 shared.require_controller(connection_id, role)?;
16842 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16843 Ok(NodeResponse::SessionRecordUpdated { record })
16844 }
16845 NodeRequest::IndexProviderSession {
16846 workspace_id,
16847 provider,
16848 identity,
16849 display_name,
16850 } => {
16851 shared.require_controller(connection_id, role)?;
16852 let record = shared.index_provider_session(
16853 workspace_id,
16854 provider,
16855 identity,
16856 display_name,
16857 )?;
16858 Ok(NodeResponse::ProviderSessionIndexed { record })
16859 }
16860 NodeRequest::ResumeSessionRecord {
16861 record_id,
16862 terminal_size,
16863 initial_prompt,
16864 } => {
16865 shared.require_controller(connection_id, role)?;
16866 if let Some(prompt) = initial_prompt.as_deref() {
16867 validate_node_text("managed resume initial prompt", prompt)?;
16868 }
16869 let (record, session) = shared
16870 .resume_session_record(
16871 &record_id,
16872 terminal_size,
16873 initial_prompt,
16874 )
16875 .await?;
16876 Ok(NodeResponse::SessionRecordResumed { record, session })
16877 }
16878 NodeRequest::ForgetSessionRecord { record_id } => {
16879 shared.require_controller(connection_id, role)?;
16880 shared.forget_session_record(&record_id).await?;
16881 Ok(NodeResponse::SessionRecordForgotten { record_id })
16882 }
16883 NodeRequest::Prompt { session, text } => {
16884 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16885 validate_node_text("prompt", &text)?;
16886 shared.require_session_runtime_policy(
16887 &session,
16888 ProviderRuntimeRequirement::SemanticPrompt,
16889 )?;
16890 let framing = prompt_framing(&agent_id);
16891 shared
16892 .dispatch_input_bounded(
16893 &session,
16894 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16895 )
16896 .await?;
16897 Ok(NodeResponse::Accepted)
16898 }
16899 NodeRequest::Paste { session, text } => {
16900 controlled_session(shared, connection_id, role, &session)?;
16901 validate_node_text("paste", &text)?;
16902 shared.require_session_runtime_policy(
16903 &session,
16904 ProviderRuntimeRequirement::SemanticPrompt,
16905 )?;
16906 shared
16907 .dispatch_input_bounded(
16908 &session,
16909 InputAction::InsertDraft(PromptPayload {
16910 text,
16911 framing: PromptFraming::BracketedPaste,
16912 }),
16913 )
16914 .await?;
16915 Ok(NodeResponse::Accepted)
16916 }
16917 NodeRequest::Input { session, text } => {
16918 controlled_session(shared, connection_id, role, &session)?;
16919 validate_node_text("terminal input", &text)?;
16920 shared
16921 .dispatch_input_bounded(
16922 &session,
16923 InputAction::TerminalText(TerminalText { text }),
16924 )
16925 .await?;
16926 Ok(NodeResponse::Accepted)
16927 }
16928 NodeRequest::TerminalBytes { session, bytes } => {
16929 controlled_session(shared, connection_id, role, &session)?;
16930 shared.sessionize_spawn(session.session.instance_id);
16931 let action = terminal_bytes_action(bytes)?;
16932 shared
16933 .dispatch_input_bounded(&session, action)
16934 .await?;
16935 Ok(NodeResponse::Accepted)
16936 }
16937 NodeRequest::TerminalControl { session, control } => {
16938 controlled_session(shared, connection_id, role, &session)?;
16939 shared
16940 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16941 .await?;
16942 Ok(NodeResponse::Accepted)
16943 }
16944 NodeRequest::Resize { session, size } => {
16945 controlled_session(shared, connection_id, role, &session)?;
16946 shared.sessionize_spawn(session.session.instance_id);
16947 if !size.is_valid() {
16948 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16949 }
16950 shared.dispatch_resize_bounded(&session, size).await?;
16951 Ok(NodeResponse::Accepted)
16952 }
16953 NodeRequest::Interrupt { session } => {
16954 controlled_session(shared, connection_id, role, &session)?;
16955 shared
16956 .dispatch_input_bounded(
16957 &session,
16958 InputAction::TerminalControl(TerminalControl::Interrupt),
16959 )
16960 .await?;
16961 Ok(NodeResponse::Accepted)
16962 }
16963 NodeRequest::Stop { session, force } => {
16964 controlled_session(shared, connection_id, role, &session)?;
16965 shared.sessionize_spawn(session.session.instance_id);
16966 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16967 shared.retire_session_grok_leader(session.session.instance_id);
16968 Ok(NodeResponse::Accepted)
16969 }
16970 NodeRequest::Remove { session } => {
16971 controlled_session(shared, connection_id, role, &session)?;
16972 shared.remove_session(&session).await?;
16973 Ok(NodeResponse::Accepted)
16974 }
16975 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16976 shared.require_controller(connection_id, role)?;
16977 let snapshot = shared.require_acp_session(&session)?;
16978 let interaction = snapshot
16979 .provider
16980 .interactions
16981 .iter()
16982 .find(|interaction| {
16983 interaction_correlation(
16984 session.session.instance_id,
16985 session.session.generation,
16986 interaction.id.0,
16987 ) == correlation_id
16988 })
16989 .ok_or_else(|| failure(
16990 NodeFailureCode::InvalidRequest,
16991 "unknown interaction correlation",
16992 ))?;
16993 if interaction.status != ProviderInteractionStatus::Pending {
16994 return Err(failure(
16995 NodeFailureCode::BackendOperationFailed,
16996 "interaction has already been resolved",
16997 ));
16998 }
16999 response
17000 .validate_for(interaction.interaction_kind)
17001 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17002 let interaction_id = interaction.id;
17003 let response_kind = response.kind();
17004 let response_outcome = response.outcome();
17005 shared.dispatch(ControlCommand::ResolveInteraction {
17006 instance_id: session.session.instance_id,
17007 generation: session.session.generation,
17008 interaction_id,
17009 response,
17010 })?;
17011 tracing::info!(
17017 node_id = %shared.node_id,
17018 session = ?session.session,
17019 correlation_id = %correlation_id,
17020 interaction_id = interaction_id.0,
17021 response_kind = ?response_kind,
17022 outcome = ?response_outcome,
17023 "interaction resolved",
17024 );
17025 Ok(NodeResponse::Accepted)
17026 }
17027 NodeRequest::SetSessionMode { session, mode_id } => {
17028 shared.require_controller(connection_id, role)?;
17029 shared.require_acp_session(&session)?;
17030 shared.dispatch(ControlCommand::SetSessionMode {
17031 instance_id: session.session.instance_id,
17032 mode_id,
17033 })?;
17034 Ok(NodeResponse::Accepted)
17035 }
17036 NodeRequest::SetSessionConfigOption {
17037 session,
17038 option_id,
17039 value_json,
17040 } => {
17041 shared.require_controller(connection_id, role)?;
17042 shared.require_acp_session(&session)?;
17043 shared.dispatch(ControlCommand::SetSessionConfigOption {
17044 instance_id: session.session.instance_id,
17045 option_id,
17046 value_json,
17047 })?;
17048 Ok(NodeResponse::Accepted)
17049 }
17050 NodeRequest::SetSessionModel { session, model_id } => {
17051 shared.require_controller(connection_id, role)?;
17052 shared.require_acp_session(&session)?;
17053 shared.dispatch(ControlCommand::SetSessionModel {
17054 instance_id: session.session.instance_id,
17055 model_id,
17056 })?;
17057 Ok(NodeResponse::Accepted)
17058 }
17059 NodeRequest::Shutdown => {
17060 shared.require_controller(connection_id, role)?;
17061 shared.begin_shutdown_locked().await?;
17062 Ok(NodeResponse::ShuttingDown)
17063 }
17064 }
17065}
17066
17067fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17068 shared.require_controller(connection_id, role)?;
17069 shared.validate_address(session)
17070}
17071
17072fn select_history_candidate(
17078 candidates: &[HistoryCandidateSummary],
17079 provider_session_id: Option<&str>,
17080) -> Result<String, NodeFailure> {
17081 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17082 Some(id) => candidate.session_id_hint == id,
17083 None => true,
17084 });
17085 let candidate = matches.next().ok_or_else(|| {
17086 failure(
17087 NodeFailureCode::SessionRecordConflict,
17088 if provider_session_id.is_some() {
17089 "no history candidate matches the managed provider session"
17090 } else {
17091 "no history candidate exists for the managed session"
17092 },
17093 )
17094 })?;
17095 if matches.next().is_some() {
17096 return Err(failure(
17097 NodeFailureCode::SessionRecordConflict,
17098 if provider_session_id.is_some() {
17099 "history identity is ambiguous for the managed provider session"
17100 } else {
17101 "history is ambiguous without a verified provider session identity"
17102 },
17103 ));
17104 }
17105 Ok(candidate.id.clone())
17106}
17107
17108fn resolved_spawn_profile_summary(
17109 profile: &SpawnProfileDefaults,
17110 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17111) -> Option<SpawnProfileSummary> {
17112 let environment_profile = match profile.environment_profile_id.as_ref() {
17113 None => None,
17114 Some(profile_id) => {
17115 let binding = environment_profiles.get(profile_id)?;
17116 if binding.provider != profile.provider
17117 || binding.native_profile_id(profile.mode).is_none()
17118 {
17119 return None;
17120 }
17121 Some(ResolvedEnvironmentProfileReceipt {
17122 profile_id: binding.id.clone(),
17123 profile_revision: binding.revision.clone(),
17124 network_allowlist: None,
17125 browser_profile_id: None,
17126 })
17127 }
17128 };
17129 Some(SpawnProfileSummary {
17130 id: profile.profile_id.clone(),
17131 revision: profile.revision.clone(),
17132 environment_profile,
17133 })
17134}
17135
17136fn session_record_export_target_matches(
17155 expected: &ManagedSessionRecord,
17156 current: &ManagedSessionRecord,
17157 session: &SessionAddress,
17158 allow_clean_detachment: bool,
17159) -> bool {
17160 if current == expected && current.active_session.as_ref() == Some(session) {
17161 return current.state == ManagedSessionState::Live;
17162 }
17163 allow_clean_detachment
17164 && current.state == ManagedSessionState::Dormant
17165 && current.active_session.is_none()
17166 && current.record_id == expected.record_id
17167 && current.display_name == expected.display_name
17168 && current.provider == expected.provider
17169 && current.mode == expected.mode
17170 && current.workspace_id == expected.workspace_id
17171 && current.canonical_root == expected.canonical_root
17172 && current.provider_session == expected.provider_session
17173 && current.environment_profile == expected.environment_profile
17174 && current.bundle == expected.bundle
17175 && current.context_id == expected.context_id
17176 && current.context == expected.context
17177 && current.exported_context == expected.exported_context
17178 && current.task_binding == expected.task_binding
17179 && current.created_at_unix_ms == expected.created_at_unix_ms
17180 && current.last_error == expected.last_error
17181}
17182
17183fn session_record_context_export_binding_is_exact(
17184 expected: &ManagedSessionRecord,
17185 current: &ManagedSessionRecord,
17186 identity: Option<&ProviderSessionIdentity>,
17187 session: &SessionAddress,
17188 binding: &SessionBinding,
17189 provider: &AgentId,
17190 current_root: &str,
17191 allow_clean_detachment: bool,
17192) -> bool {
17193 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17194 && current.provider_session.as_ref() == identity
17195 && current.workspace_id == session.workspace_id
17196 && provider == ¤t.provider
17197 && binding.workspace_id == session.workspace_id
17198 && binding.generation == session.session.generation
17199 && binding.record_id.as_ref() == Some(¤t.record_id)
17200 && platform::roots_equal(
17201 current_root,
17202 windows_path_text(¤t.canonical_root),
17203 )
17204}
17205
17206fn session_record_context_export_source_is_usable(
17207 record: &ManagedSessionRecord,
17208 status: &SessionStatus,
17209 allow_clean_detachment: bool,
17210) -> bool {
17211 (record.state == ManagedSessionState::Live
17212 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17213 && context_pack_source_status_is_usable(status)
17214}
17215
17216fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17217 PromptFraming::BracketedPaste
17218}
17219
17220fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17221 if text.len() > MAX_NODE_TEXT_BYTES {
17222 return Err(failure(
17223 NodeFailureCode::InvalidRequest,
17224 &format!(
17225 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17226 text.len(),
17227 ),
17228 ));
17229 }
17230 Ok(())
17231}
17232
17233fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17234 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17235 return Err(failure(
17236 NodeFailureCode::InvalidRequest,
17237 &format!(
17238 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17239 bytes.len(),
17240 ),
17241 ));
17242 }
17243 Ok(())
17244}
17245
17246fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17247 validate_terminal_bytes(&bytes)?;
17248 Ok(InputAction::TerminalBytes(bytes))
17249}
17250
17251fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17252 if root.is_empty()
17253 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17254 || root.chars().any(char::is_control)
17255 {
17256 return Err(failure(
17257 NodeFailureCode::InvalidWorkspaceRoot,
17258 &format!(
17259 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17260 ),
17261 ));
17262 }
17263 Ok(())
17264}
17265
17266fn canonical_standalone_workspace_candidate(
17267 workspace_id: &WorkspaceId,
17268 root: &str,
17269) -> Result<String, NodeServerError> {
17270 let path = Path::new(root);
17271 match std::fs::symlink_metadata(path) {
17272 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17273 .map(|workspace| workspace.canonical_root),
17274 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17275 if !path.is_absolute() {
17276 return Err(NodeServerError::InvalidWorkspaceRoot {
17277 workspace_id: workspace_id.clone(),
17278 path: root.to_owned(),
17279 message: "path is not absolute".to_owned(),
17280 });
17281 }
17282 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17283 workspace_id: workspace_id.clone(),
17284 path: root.to_owned(),
17285 message: "path has no standalone directory name".to_owned(),
17286 })?;
17287 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17288 workspace_id: workspace_id.clone(),
17289 path: root.to_owned(),
17290 message: "path has no parent directory".to_owned(),
17291 })?;
17292 let canonical = std::fs::canonicalize(parent)
17293 .map(|parent| parent.join(name))
17294 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17295 workspace_id: workspace_id.clone(),
17296 path: root.to_owned(),
17297 message: error.to_string(),
17298 })?;
17299 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17300 NodeServerError::InvalidWorkspaceRoot {
17301 workspace_id: workspace_id.clone(),
17302 path: path.to_string_lossy().into_owned(),
17303 message: "canonical path is not valid Unicode".to_owned(),
17304 }
17305 })?;
17306 let canonical_root = platform::normalize_canonical_root(canonical_root);
17307 validate_workspace_root(workspace_id, &canonical_root)?;
17308 Ok(canonical_root)
17309 }
17310 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17311 workspace_id: workspace_id.clone(),
17312 path: root.to_owned(),
17313 message: error.to_string(),
17314 }),
17315 }
17316}
17317
17318fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17319 let Some(revision) = revision else {
17320 return Ok(());
17321 };
17322 if revision.is_empty()
17323 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17324 || revision.chars().any(char::is_control)
17325 {
17326 return Err(failure(
17327 NodeFailureCode::InvalidRequest,
17328 &format!(
17329 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17330 ),
17331 ));
17332 }
17333 Ok(())
17334}
17335
17336fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17337 let code = match error.kind {
17338 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17339 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17340 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17341 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17342 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17343 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17344 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17345 };
17346 failure(code, &error.message)
17347}
17348
17349fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17350 let code = match error.kind {
17351 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17352 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17353 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17354 NodeFailureCode::BackendOperationFailed
17355 }
17356 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17357 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17358 }
17359 };
17360 failure(code, error.message)
17361}
17362
17363fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17364 let (code, message) = match error.kind {
17365 GitWorktreeErrorKind::Invalid => (
17366 NodeFailureCode::InvalidRequest,
17367 "managed Git input is invalid",
17368 ),
17369 GitWorktreeErrorKind::NotRepository => (
17370 NodeFailureCode::NotGitRepository,
17371 "managed source is not a Git repository",
17372 ),
17373 GitWorktreeErrorKind::Conflict => (
17374 NodeFailureCode::WorktreeConflict,
17375 "managed Git identity conflicts with existing repository state",
17376 ),
17377 GitWorktreeErrorKind::Protected => (
17378 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17379 "managed worktree ownership validation failed",
17380 ),
17381 GitWorktreeErrorKind::Dirty => (
17382 NodeFailureCode::WorktreeDirty,
17383 "managed worktree contains uncommitted changes",
17384 ),
17385 GitWorktreeErrorKind::Locked => (
17386 NodeFailureCode::WorktreeLocked,
17387 "managed worktree is locked",
17388 ),
17389 GitWorktreeErrorKind::Failed => (
17390 NodeFailureCode::BackendOperationFailed,
17391 "managed Git operation failed",
17392 ),
17393 };
17394 NodeFailure { code, message: message.to_owned() }
17395}
17396
17397fn managed_spawn_receipt(
17398 resolved: &ResolvedSpawnSpec,
17399 incarnation_id: NodeIncarnationId,
17400 session: SessionAddress,
17401 lease: ManagedWorktreeLeaseSnapshot,
17402 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17403 bundle: Option<ResolvedBundleReceipt>,
17404 context: Option<ResolvedContextPackReceipt>,
17405) -> ManagedWorktreeSpawnReceipt {
17406 let mut managed = resolved.clone();
17407 managed.target.worktree_id = Some(lease.workspace_id.clone());
17408 ManagedWorktreeSpawnReceipt {
17409 spawn: managed.receipt_with_materialization(
17410 incarnation_id,
17411 session,
17412 environment_profile,
17413 bundle,
17414 context,
17415 ),
17416 lease,
17417 }
17418}
17419
17420fn validate_selected_worktree(
17421 source_root: &str,
17422 selected_root: &str,
17423 worktrees: &[NativeGitWorktreeSnapshot],
17424) -> Result<(), NodeFailure> {
17425 if !worktrees
17426 .iter()
17427 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17428 {
17429 return Err(failure(
17430 NodeFailureCode::WorktreeProtected,
17431 "source workspace is not an authoritative non-bare Git worktree root",
17432 ));
17433 }
17434 if !worktrees.iter().any(|worktree| {
17435 worktree_paths_equal(&worktree.path, selected_root)
17436 && !worktree.is_main
17437 && !worktree.is_bare
17438 }) {
17439 return Err(failure(
17440 NodeFailureCode::WorktreeProtected,
17441 "selected workspace is not an eligible Git worktree in the source repository",
17442 ));
17443 }
17444 Ok(())
17445}
17446
17447fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17448 let code = match error.kind() {
17449 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17450 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17451 NodeFailureCode::RepositoryPathUnsafe
17452 }
17453 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17454 WorkspaceFileReadErrorKind::NotRegularFile => {
17455 NodeFailureCode::RepositoryFileNotRegular
17456 }
17457 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17458 NodeFailureCode::RepositoryFileReadFailed
17459 }
17460 WorkspaceFileReadErrorKind::RevisionConflict => {
17461 NodeFailureCode::RepositoryFileRevisionConflict
17462 }
17463 WorkspaceFileReadErrorKind::AlreadyExists
17464 | WorkspaceFileReadErrorKind::ParentNotFound
17465 | WorkspaceFileReadErrorKind::ParentNotDirectory
17466 | WorkspaceFileReadErrorKind::Canceled => {
17467 NodeFailureCode::RepositoryFileReadFailed
17468 }
17469 };
17470 failure(code, "repository file read failed")
17471}
17472
17473fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17474 let code = match error.kind() {
17475 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17476 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17477 NodeFailureCode::RepositoryPathUnsafe
17478 }
17479 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17480 WorkspaceFileReadErrorKind::NotRegularFile => {
17481 NodeFailureCode::RepositoryFileNotRegular
17482 }
17483 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17484 NodeFailureCode::RepositoryFileWriteFailed
17485 }
17486 WorkspaceFileReadErrorKind::RevisionConflict => {
17487 NodeFailureCode::RepositoryFileRevisionConflict
17488 }
17489 WorkspaceFileReadErrorKind::AlreadyExists
17490 | WorkspaceFileReadErrorKind::ParentNotFound
17491 | WorkspaceFileReadErrorKind::ParentNotDirectory
17492 | WorkspaceFileReadErrorKind::Canceled => {
17493 NodeFailureCode::RepositoryFileWriteFailed
17494 }
17495 };
17496 failure(code, "repository file write failed")
17497}
17498
17499async fn settle_workspace_entry_create<T>(
17500 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17501 commit_state: Arc<AtomicU8>,
17502 deadline: Duration,
17503 timeout_message: &'static str,
17504 task_failure_message: &'static str,
17505) -> Result<T, NodeFailure> {
17506 let joined = match timeout(deadline, &mut task).await {
17507 Ok(joined) => joined,
17508 Err(_) => {
17509 if commit_state
17510 .compare_exchange(
17511 WORKSPACE_ENTRY_CREATE_PENDING,
17512 WORKSPACE_ENTRY_CREATE_CANCELED,
17513 Ordering::AcqRel,
17514 Ordering::Acquire,
17515 )
17516 .is_ok()
17517 {
17518 return Err(failure(
17519 NodeFailureCode::RepositoryEntryCreateTimedOut,
17520 timeout_message,
17521 ));
17522 }
17523 debug_assert_eq!(
17524 commit_state.load(Ordering::Acquire),
17525 WORKSPACE_ENTRY_CREATE_COMMITTING,
17526 );
17527 task.await
17528 }
17529 };
17530 joined
17531 .map_err(|_| {
17532 failure(
17533 NodeFailureCode::RepositoryEntryCreateFailed,
17534 task_failure_message,
17535 )
17536 })?
17537 .map_err(workspace_entry_create_failure)
17538}
17539
17540fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17541 let code = match error.kind() {
17542 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17543 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17544 NodeFailureCode::RepositoryPathUnsafe
17545 }
17546 WorkspaceFileReadErrorKind::AlreadyExists => {
17547 NodeFailureCode::RepositoryEntryAlreadyExists
17548 }
17549 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17550 NodeFailureCode::RepositoryParentNotFound
17551 }
17552 WorkspaceFileReadErrorKind::ParentNotDirectory
17553 | WorkspaceFileReadErrorKind::NotRegularFile => {
17554 NodeFailureCode::RepositoryParentNotDirectory
17555 }
17556 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17557 NodeFailureCode::RepositoryEntryCreateFailed
17558 }
17559 WorkspaceFileReadErrorKind::RevisionConflict => {
17560 NodeFailureCode::RepositoryEntryCreateFailed
17561 }
17562 WorkspaceFileReadErrorKind::Canceled => {
17563 NodeFailureCode::RepositoryEntryCreateTimedOut
17564 }
17565 };
17566 failure(code, "repository entry create failed")
17567}
17568
17569fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17570 let value = digest(&SHA256, bytes)
17571 .as_ref()
17572 .iter()
17573 .map(|byte| format!("{byte:02x}"))
17574 .collect();
17575 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17576}
17577
17578fn managed_spawn_request_digest_v2(
17579 request: &ManagedWorktreeSpawnRequestV2,
17580) -> Result<String, NodeFailure> {
17581 let encoded = serde_json::to_vec(request).map_err(|_| {
17582 failure(
17583 NodeFailureCode::InvalidRequest,
17584 "managed worktree V2 request could not be canonicalized",
17585 )
17586 })?;
17587 let mut value = String::with_capacity(71);
17588 value.push_str("sha256:");
17589 for byte in digest(&SHA256, &encoded).as_ref() {
17590 use std::fmt::Write as _;
17591 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17592 }
17593 Ok(value)
17594}
17595
17596fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17597 let code = match error {
17598 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17599 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17600 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17601 NativeSessionPreviewError::UnsupportedProvider
17602 | NativeSessionPreviewError::PreviewUnavailable
17603 | NativeSessionPreviewError::SessionNotFound
17604 | NativeSessionPreviewError::AmbiguousSession => {
17605 NodeFailureCode::BackendOperationFailed
17606 }
17607 };
17608 failure(code, "native session preview failed")
17609}
17610
17611fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17612 let code = match error.kind() {
17613 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17614 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17615 };
17616 failure(code, "host directory browse failed")
17617}
17618
17619fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17620 let code = match error {
17621 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17622 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17623 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17624 NodeFailureCode::DeliveryStageConflict
17625 }
17626 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17627 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17628 NodeFailureCode::DeliveryChunkOutOfOrder
17629 }
17630 DeliveryStoreError::BlobDigestMismatch => {
17631 NodeFailureCode::DeliveryBlobDigestMismatch
17632 }
17633 DeliveryStoreError::BundleDigestMismatch => {
17634 NodeFailureCode::DeliveryBundleDigestMismatch
17635 }
17636 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17637 DeliveryStoreError::Unavailable
17638 | DeliveryStoreError::Corrupt
17639 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17640 };
17641 failure(code, "delivery operation failed")
17642}
17643
17644fn validate_workspace_root(
17645 workspace_id: &WorkspaceId,
17646 canonical_root: &str,
17647) -> Result<(), NodeServerError> {
17648 #[cfg(windows)]
17649 if canonical_root.starts_with(r"\\") {
17650 return Err(NodeServerError::InvalidWorkspaceRoot {
17651 workspace_id: workspace_id.clone(),
17652 path: canonical_root.to_owned(),
17653 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17654 });
17655 }
17656 if !platform::workspace_root_supported(canonical_root)
17657 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17658 {
17659 return Err(NodeServerError::InvalidWorkspaceRoot {
17660 workspace_id: workspace_id.clone(),
17661 path: canonical_root.to_owned(),
17662 message: "canonical path must be absolute, bounded, and free of control characters"
17663 .to_owned(),
17664 });
17665 }
17666 Ok(())
17667}
17668
17669fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17670 NodeFailure {
17671 code,
17672 message: node_failure_category(code).to_owned(),
17673 }
17674}
17675
17676fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17677 let code = match error {
17678 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17679 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17680 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17681 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17682 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17683 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17684 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17685 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17686 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17687 };
17688 failure(code, "harness MCP proxy operation failed")
17689}
17690
17691static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17695
17696fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17703 let trace = launch.trace.as_ref()?;
17704 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17705 if trace_dir.is_empty() {
17706 return None;
17707 }
17708 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17709 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17710 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17711}
17712
17713fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17714 let remaining = deadline.saturating_duration_since(Instant::now());
17715 if remaining.is_zero() {
17716 Err(failure(
17717 NodeFailureCode::SpawnDeadlineExceeded,
17718 "spawn deadline elapsed",
17719 ))
17720 } else {
17721 Ok(remaining)
17722 }
17723}
17724
17725fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17726 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17727 match deadline {
17728 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17729 None => Ok(bounded),
17730 }
17731}
17732
17733fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17734 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17735 failure(
17736 NodeFailureCode::SpawnDeadlineExceeded,
17737 "spawn dispatch exceeded the spawn deadline",
17738 )
17739 } else {
17740 error
17741 }
17742}
17743
17744fn spawn_runtime_capabilities(
17745 capabilities: &SpawnRequiredCapabilities,
17746) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17747 capabilities
17748 .iter()
17749 .map(|capability| match capability.as_str() {
17750 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17751 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17752 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17753 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17754 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17755 }
17756 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17757 _ => Err(failure(
17758 NodeFailureCode::UnsupportedSpawnCapability,
17759 "spawn required capability is unknown",
17760 )),
17761 })
17762 .collect()
17763}
17764
17765fn persistence_failure(_error: io::Error) -> NodeFailure {
17766 NodeFailure {
17767 code: NodeFailureCode::BackendOperationFailed,
17768 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17769 }
17770}
17771
17772fn node_failure_category(code: NodeFailureCode) -> &'static str {
17773 match code {
17774 NodeFailureCode::InvalidRequest => "invalid-request",
17775 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17776 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17777 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17778 NodeFailureCode::ReservationConflict => "reservation-conflict",
17779 NodeFailureCode::ReservationExpired => "reservation-expired",
17780 NodeFailureCode::BindingMismatch => "binding-mismatch",
17781 NodeFailureCode::NotActivated => "not-activated",
17782 NodeFailureCode::CallNotFound => "call-not-found",
17783 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17784 NodeFailureCode::ResponseTooLarge => "response-too-large",
17785 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17786 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17787 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17788 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17789 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17790 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17791 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17792 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17793 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17794 NodeFailureCode::Unauthorized => "unauthorized",
17795 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17796 NodeFailureCode::ControllerBusy => "controller-busy",
17797 NodeFailureCode::ControllerRequired => "controller-required",
17798 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17799 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17800 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17801 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17802 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17803 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17804 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17805 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17806 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17807 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17808 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17809 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17810 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17811 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17812 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17813 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17814 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17815 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17816 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17817 NodeFailureCode::GitReadFailed => "git-read-failed",
17818 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17819 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17820 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17821 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17822 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17823 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17824 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17825 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17826 NodeFailureCode::TurnInFlight => "turn-in-flight",
17827 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17828 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17829 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17830 "unsupported-network-allowlist-mapping"
17831 }
17832 NodeFailureCode::BrowserStationProbeUnavailable => {
17833 "browser-station-probe-unavailable"
17834 }
17835 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17836 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17837 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17838 "environment-profile-binding-mismatch"
17839 }
17840 NodeFailureCode::UnknownBundle => "unknown-bundle",
17841 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17842 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17843 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17844 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17845 NodeFailureCode::ContextPackMaterializationFailed => {
17846 "context-pack-materialization-failed"
17847 }
17848 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17849 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17850 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17851 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17852 NodeFailureCode::LastWorkspace => "last-workspace",
17853 NodeFailureCode::NotGitRepository => "not-git-repository",
17854 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17855 NodeFailureCode::WorktreeProtected => "worktree-protected",
17856 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17857 NodeFailureCode::WorktreeLocked => "worktree-locked",
17858 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17859 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17860 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17861 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17862 "managed-worktree-profile-revision-mismatch"
17863 }
17864 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17865 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17866 "standalone-workspace-recovery-required"
17867 }
17868 NodeFailureCode::UnknownSession => "unknown-session",
17869 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17870 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17871 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17872 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17873 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17874 NodeFailureCode::WorkspaceRegistrationRequired => {
17875 "workspace-registration-required"
17876 }
17877 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17878 NodeFailureCode::StaleGeneration => "stale-generation",
17879 NodeFailureCode::BackendBusy => "backend-busy",
17880 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17881 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17882 NodeFailureCode::ShuttingDown => "shutting-down",
17883 }
17884}
17885
17886fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17887 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17888}
17889
17890fn durable_state_load_error(error: io::Error) -> NodeServerError {
17891 let category = match session_registry::state_load_refusal(&error) {
17892 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17893 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17894 }
17895 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17896 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17897 }
17898 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17899 DURABLE_STATE_CONFLICT_ERROR
17900 }
17901 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17902 };
17903 durable_state_server_error(error, category)
17904}
17905
17906fn unix_time_ms() -> u64 {
17907 std::time::SystemTime::now()
17908 .duration_since(std::time::UNIX_EPOCH)
17909 .unwrap_or_default()
17910 .as_millis()
17911 .min(u64::MAX as u128) as u64
17912}
17913
17914fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17915 if record.provider_session.is_some() {
17916 ManagedSessionState::Dormant
17917 } else {
17918 ManagedSessionState::Unavailable
17919 }
17920}
17921
17922fn active_record_state(
17923 identity_admitted: bool,
17924 provider_session_is_known: bool,
17925) -> ManagedSessionState {
17926 if identity_admitted && !provider_session_is_known {
17927 ManagedSessionState::IdentityPending
17928 } else {
17929 ManagedSessionState::Live
17930 }
17931}
17932
17933fn managed_worktree_record_holders(
17934 records: &[ManagedSessionRecord],
17935) -> Vec<(SessionRecordId, WorkspaceId)> {
17936 records
17937 .iter()
17938 .filter(|record| record.provider_session.is_some())
17939 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17940 .collect()
17941}
17942
17943fn managed_spawn_record_ownership(
17944 binding: &SessionBinding,
17945 effective_runtime_policy: ProviderRuntimePolicy,
17946) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17947 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17948 if effective_runtime_policy.provider_session_identity {
17949 (binding.record_id.clone(), None)
17950 } else {
17951 (None, binding.record_id.clone())
17952 }
17953}
17954
17955#[derive(Debug, Error)]
17956pub enum NodeServerError {
17957 #[error("node endpoint must be a bounded absolute local transport path")]
17958 InvalidEndpoint,
17959 #[error("node access token must contain 1..=4096 bytes")]
17960 InvalidAccessToken,
17961 #[error("node HTTP observer must listen on a loopback address: {0}")]
17962 InvalidApiListen(std::net::SocketAddr),
17963 #[error("node browser bridge must listen on a loopback address: {0}")]
17964 InvalidBridgeListen(std::net::SocketAddr),
17965 #[error("node browser bridge token must contain 1..=4096 bytes")]
17966 InvalidBridgeToken,
17967 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17968 BridgeUnderlayRequiresBridgeListen,
17969 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17970 BridgeUnderlayRequiresBridgeToken,
17971 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17972 InvalidBridgeUnderlayToken,
17973 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17974 InvalidBridgeUnderlayKey,
17975 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17976 InvalidCallHome(std::net::SocketAddr),
17977 #[error("node could not reach its call-home relay: {0}")]
17978 CallHomeTransport(#[source] std::io::Error),
17979 #[error("node durable state path must be an absolute file path: {0}")]
17980 InvalidStatePath(String),
17981 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17982 InvalidHarnessMcpHelper,
17983 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17984 InvalidNetworkAllowlistCatalogPath(String),
17985 #[error("network allowlist catalog could not be read: {0}")]
17986 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17987 #[error("network allowlist catalog line {line}: {message}")]
17988 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17989 #[error("network allowlist catalog: {0}")]
17990 InvalidNetworkAllowlistCatalog(String),
17991 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17992 NetworkAllowlistCatalogCapacity { max: usize },
17993 #[error("the local state directory is unavailable; supply an explicit state path")]
17994 LocalStateDirectoryUnavailable,
17995 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17996 LocalRuntimeDirectoryUnavailable,
17997 #[error("node durable state failed: {0}")]
17998 DurableState(io::Error),
17999 #[error("node requires at least one configured workspace")]
18000 NoWorkspaces,
18001 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18002 InvalidWorkspaceRoot {
18003 workspace_id: WorkspaceId,
18004 path: String,
18005 message: String,
18006 },
18007 #[error("duplicate workspace ID: {0}")]
18008 DuplicateWorkspaceId(WorkspaceId),
18009 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18010 DuplicateWorkspaceRoot {
18011 first: WorkspaceId,
18012 second: WorkspaceId,
18013 root: String,
18014 },
18015 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18016 ManagedWorktreeProfileRequired(WorkspaceId),
18017 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18018 ManagedWorktreeProfileModeMismatch(WorkspaceId),
18019 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18020 DuplicateManagedWorktreeProfile {
18021 workspace_id: WorkspaceId,
18022 profile_id: WorktreeProfileId,
18023 },
18024 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18025 ManagedWorktreeProfileCapacity {
18026 workspace_id: WorkspaceId,
18027 max: usize,
18028 },
18029 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18030 InvalidManagedWorktreeProfile {
18031 workspace_id: WorkspaceId,
18032 profile_id: WorktreeProfileId,
18033 message: String,
18034 },
18035 #[error("duplicate node environment profile: {0}")]
18036 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18037 #[error("node environment profile provider is unavailable: {0}")]
18038 EnvironmentProfileProviderUnavailable(AgentId),
18039 #[error("node environment profile capacity is {max}")]
18040 EnvironmentProfileCapacity { max: usize },
18041 #[error("node environment profiles reuse native profile ID '{0}'")]
18042 DuplicateNativeEnvironmentProfile(String),
18043 #[error("node native environment profile is invalid: {0}")]
18044 NativeEnvironmentProfile(String),
18045 #[error("node session-environment materialization root must be absolute")]
18046 InvalidSessionEnvironmentRoot,
18047 #[error("node session-environment materializer is unavailable")]
18048 SessionEnvironmentMaterializerRequired,
18049 #[error("node session-environment materializer failed to initialize")]
18050 SessionEnvironmentMaterializer,
18051 #[error("node bundle catalog is invalid: {0}")]
18052 BundleCatalog(String),
18053 #[error("node delivery store failed to initialize: {0}")]
18054 DeliveryStore(String),
18055 #[error("node context pack store failed to initialize")]
18056 ContextPackStore,
18057 #[error("active agent registry failed: {0}")]
18058 Registry(String),
18059 #[error("node provider contract manifest is invalid: {0}")]
18060 ProviderContractManifest(String),
18061 #[error("named pipe I/O failed: {0}")]
18062 Io(#[from] io::Error),
18063 #[error("node HTTP observer failed: {0}")]
18064 HttpApi(io::Error),
18065 #[error("node browser bridge failed: {0}")]
18066 BridgeApi(io::Error),
18067 #[error(transparent)]
18068 Frame(#[from] FrameError),
18069 #[error("node handshake failed: {0}")]
18070 Handshake(String),
18071 #[error("node authentication frame was not received before the bounded deadline")]
18072 AuthenticationTimedOut,
18073 #[error("node authentication primitive failed: {0}")]
18074 Authentication(String),
18075 #[error("node incarnation identity generation failed: {0}")]
18076 IncarnationIdentity(String),
18077 #[error("node pre-authentication limiter is closed")]
18078 PreauthClosed,
18079 #[error("node authenticated connection limit was reached")]
18080 AuthenticatedConnectionLimit,
18081 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18082 SignalStreamClosed,
18083 #[error("node stop-all dispatch failed: {0:?}")]
18084 ShutdownDispatch(NodeFailure),
18085 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18086 ShutdownTimedOut { active_native_sessions: usize },
18087}
18088
18089impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18090 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18091 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18092 match error {
18093 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18094 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18095 E::InvalidEntry { line, message } => {
18096 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18097 }
18098 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18099 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18100 }
18101 }
18102}
18103
18104#[cfg(test)]
18105mod agent_stream_blocked_tests {
18106 use super::*;
18107 use gate4agent_types::{
18108 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18109 ProviderSource,
18110 };
18111
18112 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18113 ProviderSource {
18114 family,
18115 binding: AdapterBinding::new(
18116 AdapterId::new(adapter).unwrap(),
18117 "fixture/v1",
18118 AdapterVerification::SyntheticFixture,
18119 )
18120 .unwrap(),
18121 }
18122 }
18123
18124 fn provider_control_event_at(
18125 family: AdapterFamily,
18126 adapter: &str,
18127 source_sequence: u64,
18128 provider_event: ProviderEvent,
18129 ) -> ControlEvent {
18130 ControlEvent {
18131 sequence: 41,
18132 command_id: None,
18133 instance_id: AgentInstanceId(7),
18134 generation: SessionGeneration(3),
18135 event: ControlEventKind::ProviderEvent {
18136 sequence: 8,
18137 source: provider_source(family, adapter),
18138 source_sequence,
18139 event: provider_event,
18140 },
18141 }
18142 }
18143
18144 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18145 provider_control_event_at(family, "fixture", 9, provider_event)
18146 }
18147
18148 fn denied_host_request(
18149 by: ProviderHostDecisionAuthority,
18150 reason: Option<String>,
18151 ) -> ControlEvent {
18152 provider_control_event_at(
18153 AdapterFamily::Acp,
18154 "claude-code",
18155 2,
18156 ProviderEvent::HostRequestObserved {
18157 method: "terminal/create".to_owned(),
18158 params_json: String::new(),
18159 decision: ProviderHostRequestDecision::Denied { by },
18160 outcome: ProviderHostRequestOutcome::Executed,
18161 reason,
18162 },
18163 )
18164 }
18165
18166 fn blocked(
18167 event: &ControlEvent,
18168 ) -> (
18169 Option<String>,
18170 String,
18171 BlockAuthorityV1,
18172 Option<String>,
18173 String,
18174 Option<String>,
18175 ) {
18176 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18177 chunk.validate().expect("minted chunk must validate");
18178 let AgentStreamChunkKindV1::Blocked {
18179 correlation_id,
18180 tool_class,
18181 authority,
18182 reason_kind,
18183 reason,
18184 help,
18185 } = chunk.kind
18186 else {
18187 panic!("expected AgentStreamChunkKindV1::Blocked");
18188 };
18189 (correlation_id, tool_class, authority, reason_kind, reason, help)
18190 }
18191
18192 #[test]
18196 fn the_enabled_provider_matrix_is_exact() {
18197 let registry = active_registry().unwrap();
18198 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18199 assert_eq!(
18200 providers
18201 .iter()
18202 .map(|contract| contract.provider.as_str())
18203 .collect::<Vec<_>>(),
18204 vec!["shell", "claude", "codex", "grok", "kimi"],
18206 );
18207 assert!(adapters.iter().all(|contract| {
18208 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18209 }));
18210 }
18211
18212 #[test]
18215 fn gate_denied_host_request_mints_a_blocked_chunk() {
18216 let gate_text =
18217 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18218 let event = denied_host_request(
18219 ProviderHostDecisionAuthority::Gate,
18220 Some(gate_text.to_owned()),
18221 );
18222 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18223 assert_eq!(chunk.source_sequence, 2);
18224 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18225 assert_eq!(authority, BlockAuthorityV1::HostGate);
18226 assert_eq!(reason, gate_text);
18227 assert_eq!(correlation_id, None);
18228 assert_eq!(reason_kind, None);
18229 assert_eq!(help, None);
18230 assert_eq!(tool_class, "Write");
18231 }
18232
18233 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.";
18234
18235 #[test]
18241 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18242 let event = provider_control_event(
18243 AdapterFamily::Acp,
18244 ProviderEvent::ToolCompleted {
18245 id: "toolu_9".to_owned(),
18246 output: CLASSIFIER_SAMPLE.to_owned(),
18247 is_error: true,
18248 duration_ms: None,
18249 agent_id: None,
18250 non_execution_kind: Some("permission-rule".to_owned()),
18251 },
18252 );
18253 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18254 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18255 assert_eq!(reason, "Blocked by classifier");
18256 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18257 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18258 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18259 }
18260
18261 #[test]
18266 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18267 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.";
18268 let event = provider_control_event(
18269 AdapterFamily::Acp,
18270 ProviderEvent::SessionEnded {
18271 result: "JSON-RPC error -32603: Internal error".to_owned(),
18272 cost_usd: None,
18273 is_error: true,
18274 stop_reason: Some(ProviderStopReason::ProviderError {
18275 code: -32603,
18276 message: message.to_owned(),
18277 vendor_code: Some("usageLimitExceeded".to_owned()),
18278 }),
18279 },
18280 );
18281 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18282 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18283 assert_eq!(reason, message);
18284 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18285 assert_eq!(tool_class, "Session");
18286 }
18287
18288 #[test]
18291 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18292 let event = provider_control_event(
18293 AdapterFamily::Acp,
18294 ProviderEvent::SessionEnded {
18295 result: "refusal".to_owned(),
18296 cost_usd: None,
18297 is_error: true,
18298 stop_reason: Some(ProviderStopReason::Refusal),
18299 },
18300 );
18301 let (_, _, authority, _, _, _) = blocked(&event);
18302 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18303 }
18304
18305 #[test]
18309 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18310 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18311 let event = provider_control_event(
18312 AdapterFamily::Acp,
18313 ProviderEvent::ToolCompleted {
18314 id: "toolu_1".to_owned(),
18315 output: "command exited with status 1".to_owned(),
18316 is_error: true,
18317 duration_ms: Some(12),
18318 agent_id: None,
18319 non_execution_kind,
18320 },
18321 );
18322 assert!(agent_stream_chunk(&event).is_none());
18323 }
18324 let narrative = provider_control_event(
18325 AdapterFamily::Acp,
18326 ProviderEvent::ToolCompleted {
18327 id: "toolu_2".to_owned(),
18328 output: "the harness blocked the write, approval needed".to_owned(),
18329 is_error: false,
18330 duration_ms: Some(3),
18331 agent_id: None,
18332 non_execution_kind: None,
18333 },
18334 );
18335 assert!(agent_stream_chunk(&narrative).is_none());
18336 }
18337
18338 #[test]
18342 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18343 for (decision, outcome) in [
18344 (
18345 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18346 ProviderHostRequestOutcome::Executed,
18347 ),
18348 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18349 (
18350 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18351 ProviderHostRequestOutcome::Failed {
18352 error: "terminal/create spawn failed: os error 3".to_owned(),
18353 },
18354 ),
18355 ] {
18356 let event = provider_control_event_at(
18357 AdapterFamily::Acp,
18358 "claude-code",
18359 3,
18360 ProviderEvent::HostRequestObserved {
18361 method: "fs/read_text_file".to_owned(),
18362 params_json: String::new(),
18363 decision: decision.clone(),
18364 outcome,
18365 reason: None,
18366 },
18367 );
18368 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18369 }
18370 }
18371
18372 #[test]
18376 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18377 for (by, expected_authority, expected_reason) in [
18378 (
18379 ProviderHostDecisionAuthority::Operator,
18380 BlockAuthorityV1::Operator,
18381 "declined by the operator",
18382 ),
18383 (
18384 ProviderHostDecisionAuthority::DeadlinePolicy,
18385 BlockAuthorityV1::HostDeadline,
18386 "denied by host policy after the operator deadline expired",
18387 ),
18388 ] {
18389 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18390 assert_eq!(authority, expected_authority);
18391 assert_eq!(reason, expected_reason);
18392 }
18393 }
18394
18395 #[test]
18399 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18400 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18401 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18402 ProviderHostDecisionAuthority::Policy,
18403 Some(oversized),
18404 ));
18405 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18406 }
18407}
18408
18409#[cfg(test)]
18410mod standalone_capability_platform_tests {
18411 use super::*;
18412
18413 #[test]
18414 fn standalone_workspace_capability_requires_windows_identity_guards() {
18415 let capabilities = baseline_capabilities().unwrap();
18416 let advertised = capabilities.iter().any(|capability| {
18417 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18418 });
18419 assert_eq!(advertised, cfg!(windows));
18420
18421 let request = NodeRequest::CreateStandaloneWorkspace {
18422 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18423 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18424 initial_branch: None,
18425 };
18426 assert_eq!(
18427 request_uses_unnegotiated_capability(&request, &capabilities),
18428 !cfg!(windows),
18429 );
18430 }
18431}
18432
18433
18434#[cfg(test)]
18435mod station_knobs_resolve_tests {
18436 use super::*;
18437 use gate4agent_catalog::EnvMutation;
18438 use gate4agent_runtime_native::{
18439 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18440 NativeLaunchProfileId,
18441 };
18442 use std::ffi::OsString;
18443 use std::sync::Arc;
18444
18445 struct EmptyEnvironmentResolver;
18446
18447 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18448 fn resolve_child_environment(
18449 &self,
18450 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18451 Ok(vec![EnvMutation {
18452 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18453 value: None,
18454 }])
18455 }
18456 }
18457
18458 fn agent(value: &str) -> AgentId {
18459 AgentId::new(value).unwrap()
18460 }
18461
18462 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18463 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18464 let profile_revision =
18465 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18466 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18467 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18468 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18469 provider: agent("claude"),
18470 mode: SessionMode::Pty,
18471 terminal_size: gate4agent_types::TerminalSize {
18472 rows: 24,
18473 columns: 80,
18474 },
18475 prompt: None,
18476 bundle_id: None,
18477 context_id: None,
18478 environment_profile_id: Some(profile_id.clone()),
18479 };
18480 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18481 let catalog = active_registry().unwrap();
18482 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18483 let control = runtime.native_launch_profile_control();
18484 let node_profile = NodeEnvironmentProfile::new(
18485 profile_id.clone(),
18486 profile_revision,
18487 agent("claude"),
18488 [NativeLaunchProfile::new(
18489 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18490 agent("claude"),
18491 TransportKind::Pty,
18492 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18493 Arc::new(EmptyEnvironmentResolver),
18494 )
18495 .unwrap()],
18496 )
18497 .unwrap();
18498 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18499 for native_profile in native_profiles {
18500 runtime.upsert_native_launch_profile(native_profile).unwrap();
18501 }
18502 let workspace = WorkspaceConfig::new(
18503 WorkspaceId::new("primary").unwrap(),
18504 std::env::current_dir().unwrap(),
18505 )
18506 .unwrap();
18507 let shared = NodeShared::new_with_incarnation(
18508 handle,
18509 "fixture-token".to_owned(),
18510 NodeId::new("node-station-knobs-test").unwrap(),
18511 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18512 vec![workspace],
18513 vec![agent("claude")],
18514 ProviderRuntimeStatuses::default(),
18515 None,
18516 Vec::new(),
18517 Vec::new(),
18518 spawn_profiles,
18519 Some(control),
18520 None,
18521 None,
18522 None,
18523 Vec::new(),
18524 Vec::new(),
18525 Vec::new(),
18526 Vec::new(),
18527 None,
18528 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18529 );
18530 shared
18531 .environment_profiles
18532 .write()
18533 .unwrap_or_else(|poisoned| poisoned.into_inner())
18534 .insert(binding.id.clone(), binding);
18535 (shared, spawn_defaults)
18536 }
18537
18538 #[test]
18541 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18542 let (shared, defaults) = station_knobs_fixture();
18543 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18544 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18545 let spec = crate::protocol::SpawnSpec {
18546 target: crate::protocol::SpawnTarget {
18547 node_id: shared.node_id.clone(),
18548 workspace_id: WorkspaceId::new("primary").unwrap(),
18549 worktree_id: None,
18550 },
18551 profile_id: defaults.profile_id.clone(),
18552 expected_profile_revision: defaults.revision.clone(),
18553 overrides: crate::protocol::SpawnOverrides {
18554 network_allowlist: Some(unknown),
18555 browser_profile_id: Some(browser),
18556 ..crate::protocol::SpawnOverrides::default()
18557 },
18558 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18559 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18560 "station-knobs-unknown-allowlist",
18561 )
18562 .unwrap(),
18563 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18564 };
18565 let resolved = spec.resolve(&defaults).unwrap();
18566 let failure = shared
18567 .resolve_environment_profile(&resolved)
18568 .expect_err("unknown allowlist must refuse");
18569 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18570 }
18571
18572 #[test]
18578 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18579 let (shared, defaults) = station_knobs_fixture();
18580 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18581 #[cfg(not(feature = "dig2-station-probe"))]
18582 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18583 shared
18584 .network_allowlist_catalog
18585 .write()
18586 .unwrap_or_else(|poisoned| poisoned.into_inner())
18587 .insert_id(allowlist.clone())
18588 .unwrap();
18589 let spec = crate::protocol::SpawnSpec {
18590 target: crate::protocol::SpawnTarget {
18591 node_id: shared.node_id.clone(),
18592 workspace_id: WorkspaceId::new("primary").unwrap(),
18593 worktree_id: None,
18594 },
18595 profile_id: defaults.profile_id.clone(),
18596 expected_profile_revision: defaults.revision.clone(),
18597 overrides: crate::protocol::SpawnOverrides {
18598 network_allowlist: Some(allowlist.clone()),
18599 #[cfg(not(feature = "dig2-station-probe"))]
18600 browser_profile_id: Some(browser.clone()),
18601 ..crate::protocol::SpawnOverrides::default()
18602 },
18603 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18604 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18605 .unwrap(),
18606 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18607 };
18608 let resolved = spec.resolve(&defaults).unwrap();
18609 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18610 #[cfg(not(feature = "dig2-station-probe"))]
18611 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18612 let receipt = shared
18613 .resolve_environment_profile(&resolved)
18614 .expect("resolve ok")
18615 .expect("env profile present");
18616 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18617 #[cfg(not(feature = "dig2-station-probe"))]
18618 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18619 #[cfg(feature = "dig2-station-probe")]
18620 assert_eq!(receipt.browser_profile_id, None);
18621 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18622
18623 let omitted = crate::protocol::SpawnSpec {
18624 overrides: crate::protocol::SpawnOverrides::default(),
18625 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18626 .unwrap(),
18627 ..spec
18628 };
18629 let resolved_omit = omitted.resolve(&defaults).unwrap();
18630 let bare = shared
18631 .resolve_environment_profile(&resolved_omit)
18632 .expect("resolve ok")
18633 .expect("env profile present");
18634 assert_eq!(bare.network_allowlist, None);
18635 assert_eq!(bare.browser_profile_id, None);
18636 }
18637
18638 #[cfg(feature = "dig2-station-probe")]
18643 #[test]
18644 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18645 let (shared, defaults) = station_knobs_fixture();
18646 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18647 let spec = crate::protocol::SpawnSpec {
18648 target: crate::protocol::SpawnTarget {
18649 node_id: shared.node_id.clone(),
18650 workspace_id: WorkspaceId::new("primary").unwrap(),
18651 worktree_id: None,
18652 },
18653 profile_id: defaults.profile_id.clone(),
18654 expected_profile_revision: defaults.revision.clone(),
18655 overrides: crate::protocol::SpawnOverrides {
18656 browser_profile_id: Some(browser),
18657 ..crate::protocol::SpawnOverrides::default()
18658 },
18659 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18660 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18661 "station-knobs-probe-refuse",
18662 )
18663 .unwrap(),
18664 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18665 };
18666 let resolved = spec.resolve(&defaults).unwrap();
18667 let failure = shared
18668 .resolve_environment_profile(&resolved)
18669 .expect_err("browser_profile with failing probe must refuse");
18670 #[cfg(windows)]
18671 {
18672 assert!(
18673 matches!(
18674 failure.code,
18675 NodeFailureCode::BrowserStationUnreachable
18676 | NodeFailureCode::BrowserStationProbeUnavailable
18677 ),
18678 "unexpected code {:?}",
18679 failure.code
18680 );
18681 }
18682 #[cfg(not(windows))]
18683 {
18684 assert_eq!(
18685 failure.code,
18686 NodeFailureCode::BrowserStationProbeUnavailable
18687 );
18688 }
18689 }
18690
18691 #[cfg(feature = "dig2-station-probe")]
18693 #[test]
18694 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18695 let (shared, defaults) = station_knobs_fixture();
18696 let spec = crate::protocol::SpawnSpec {
18697 target: crate::protocol::SpawnTarget {
18698 node_id: shared.node_id.clone(),
18699 workspace_id: WorkspaceId::new("primary").unwrap(),
18700 worktree_id: None,
18701 },
18702 profile_id: defaults.profile_id.clone(),
18703 expected_profile_revision: defaults.revision.clone(),
18704 overrides: crate::protocol::SpawnOverrides::default(),
18705 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18706 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18707 "station-knobs-probe-omit",
18708 )
18709 .unwrap(),
18710 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18711 };
18712 let resolved = spec.resolve(&defaults).unwrap();
18713 let receipt = shared
18714 .resolve_environment_profile(&resolved)
18715 .expect("omit browser_profile must not probe")
18716 .expect("env profile present");
18717 assert_eq!(receipt.browser_profile_id, None);
18718 }
18719
18720 #[cfg(feature = "dig2-station-probe")]
18724 #[test]
18725 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18726 let (shared, _defaults) = station_knobs_fixture();
18727 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18728 let workspace_id = WorkspaceId::new("primary").unwrap();
18729 let address_a = SessionAddress {
18730 workspace_id: workspace_id.clone(),
18731 session: SessionKey {
18732 instance_id: AgentInstanceId(101),
18733 generation: SessionGeneration(1),
18734 },
18735 };
18736 let address_b = SessionAddress {
18737 workspace_id: workspace_id.clone(),
18738 session: SessionKey {
18739 instance_id: AgentInstanceId(102),
18740 generation: SessionGeneration(1),
18741 },
18742 };
18743 let guard_a = crate::dig2_station_lease::acquire_lease(
18744 &shared.browser_station_leases,
18745 profile.clone(),
18746 address_a.session.instance_id,
18747 1,
18748 )
18749 .expect("first lease");
18750 let busy = crate::dig2_station_lease::acquire_lease(
18751 &shared.browser_station_leases,
18752 profile.clone(),
18753 address_b.session.instance_id,
18754 2,
18755 )
18756 .expect_err("second holder must refuse");
18757 assert!(matches!(
18758 busy,
18759 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18760 ));
18761 shared.bind_session_with_policy(
18763 &address_a,
18764 ProviderRuntimePolicy::raw_pty(),
18765 None,
18766 );
18767 guard_a.retain();
18768 assert!(shared.remove_binding(&address_a).is_some());
18769 crate::dig2_station_lease::acquire_lease(
18770 &shared.browser_station_leases,
18771 profile,
18772 address_b.session.instance_id,
18773 3,
18774 )
18775 .expect("reacquire after clear")
18776 .retain();
18777 assert!(
18778 crate::dig2_station_lease::release_lease_for_holder(
18779 &shared.browser_station_leases,
18780 address_b.session.instance_id,
18781 )
18782 );
18783 }
18784
18785 #[cfg(not(feature = "dig2-station-probe"))]
18788 #[test]
18789 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18790 let (shared, defaults) = station_knobs_fixture();
18791 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18792 let spec = crate::protocol::SpawnSpec {
18793 target: crate::protocol::SpawnTarget {
18794 node_id: shared.node_id.clone(),
18795 workspace_id: WorkspaceId::new("primary").unwrap(),
18796 worktree_id: None,
18797 },
18798 profile_id: defaults.profile_id.clone(),
18799 expected_profile_revision: defaults.revision.clone(),
18800 overrides: crate::protocol::SpawnOverrides {
18801 browser_profile_id: Some(browser.clone()),
18802 ..crate::protocol::SpawnOverrides::default()
18803 },
18804 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18805 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18806 .unwrap(),
18807 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18808 };
18809 let resolved = spec.resolve(&defaults).unwrap();
18810 let receipt = shared
18811 .resolve_environment_profile(&resolved)
18812 .expect("stub resolve")
18813 .expect("env profile");
18814 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18815 }
18816
18817
18818 #[test]
18821 fn network_allowlist_catalog_loads_from_temp_file() {
18822 let unique = std::time::SystemTime::now()
18823 .duration_since(std::time::UNIX_EPOCH)
18824 .unwrap()
18825 .as_nanos();
18826 let path = std::env::temp_dir().join(format!(
18827 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18828 std::process::id(),
18829 ));
18830 std::fs::write(
18831 &path,
18832 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18833 )
18834 .unwrap();
18835 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18836 .expect("temp catalog file must load");
18837 let _ = std::fs::remove_file(&path);
18838 assert_eq!(loaded.len(), 2);
18839 let (shared, defaults) = station_knobs_fixture();
18840 {
18841 let mut catalog = shared
18842 .network_allowlist_catalog
18843 .write()
18844 .unwrap_or_else(|poisoned| poisoned.into_inner());
18845 *catalog = loaded;
18846 }
18847 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18848 let spec = crate::protocol::SpawnSpec {
18849 target: crate::protocol::SpawnTarget {
18850 node_id: shared.node_id.clone(),
18851 workspace_id: WorkspaceId::new("primary").unwrap(),
18852 worktree_id: None,
18853 },
18854 profile_id: defaults.profile_id.clone(),
18855 expected_profile_revision: defaults.revision.clone(),
18856 overrides: crate::protocol::SpawnOverrides {
18857 network_allowlist: Some(allowlist.clone()),
18858 ..crate::protocol::SpawnOverrides::default()
18859 },
18860 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18861 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18862 "station-knobs-catalog-file",
18863 )
18864 .unwrap(),
18865 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18866 };
18867 let resolved = spec.resolve(&defaults).unwrap();
18868 let receipt = shared
18869 .resolve_environment_profile(&resolved)
18870 .expect("registered via file load")
18871 .expect("env profile present");
18872 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18873 }
18874
18875 #[test]
18878 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18879 let (shared, _defaults) = station_knobs_fixture();
18880 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18881 shared
18882 .network_allowlist_catalog
18883 .write()
18884 .unwrap_or_else(|poisoned| poisoned.into_inner())
18885 .insert_id(allowlist.clone())
18886 .unwrap();
18887 let snapshot = shared.snapshot();
18888 let inventory = snapshot
18889 .launch_inventory
18890 .expect("launch inventory present");
18891 let ids = inventory
18892 .network_allowlists
18893 .expect("non-empty catalog surfaces ids");
18894 assert_eq!(ids, vec![allowlist]);
18895 }
18896
18897 #[test]
18901 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18902 let (shared, defaults) = station_knobs_fixture();
18903 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18904 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18905 id: allowlist.clone(),
18906 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18907 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18908 peer: "127.0.0.1:443".to_owned(),
18909 }],
18910 provider_native: Some(
18911 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18912 codex_network_access: Some(true),
18913 },
18914 ),
18915 };
18916 shared
18917 .network_allowlist_catalog
18918 .write()
18919 .unwrap_or_else(|poisoned| poisoned.into_inner())
18920 .insert(entry)
18921 .unwrap();
18922 let spec = crate::protocol::SpawnSpec {
18923 target: crate::protocol::SpawnTarget {
18924 node_id: shared.node_id.clone(),
18925 workspace_id: WorkspaceId::new("primary").unwrap(),
18926 worktree_id: None,
18927 },
18928 profile_id: defaults.profile_id.clone(),
18929 expected_profile_revision: defaults.revision.clone(),
18930 overrides: crate::protocol::SpawnOverrides {
18931 network_allowlist: Some(allowlist),
18932 ..crate::protocol::SpawnOverrides::default()
18933 },
18934 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18935 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18936 "station-knobs-unsupported-mapping",
18937 )
18938 .unwrap(),
18939 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18940 };
18941 let resolved = spec.resolve(&defaults).unwrap();
18942 assert_eq!(resolved.provider.as_str(), "claude");
18943 let failure = shared
18944 .resolve_environment_profile(&resolved)
18945 .expect_err("claude + codex_network_access must refuse");
18946 assert_eq!(
18947 failure.code,
18948 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18949 );
18950 }
18951
18952 #[test]
18956 fn resolve_codex_network_access_overlay_moderate_only() {
18957 let (shared, defaults) = station_knobs_fixture();
18958 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18959 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18960 id: allowlist.clone(),
18961 permits: Vec::new(),
18962 provider_native: Some(
18963 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18964 codex_network_access: Some(true),
18965 },
18966 ),
18967 };
18968 shared
18969 .network_allowlist_catalog
18970 .write()
18971 .unwrap_or_else(|poisoned| poisoned.into_inner())
18972 .insert(entry)
18973 .unwrap();
18974
18975 let overlay = shared
18976 .resolve_codex_network_access_overlay_args(
18977 &agent("codex"),
18978 SessionMode::Pty,
18979 ApprovalLevel::Moderate,
18980 Some(&allowlist),
18981 )
18982 .expect("Moderate Codex maps network_access");
18983 assert_eq!(
18984 overlay,
18985 vec![
18986 std::ffi::OsString::from("-c"),
18987 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18988 ]
18989 );
18990
18991 let acp = shared
18992 .resolve_codex_network_access_overlay_args(
18993 &agent("codex"),
18994 SessionMode::Acp,
18995 ApprovalLevel::Moderate,
18996 Some(&allowlist),
18997 )
18998 .unwrap();
18999 assert_eq!(acp, overlay);
19000
19001 let inline = shared
19002 .resolve_codex_network_access_overlay_args(
19003 &agent("codex"),
19004 SessionMode::Inline,
19005 ApprovalLevel::Moderate,
19006 Some(&allowlist),
19007 )
19008 .unwrap_err();
19009 assert_eq!(
19010 inline.code,
19011 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19012 );
19013
19014 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19015 let spec = crate::protocol::SpawnSpec {
19016 target: crate::protocol::SpawnTarget {
19017 node_id: shared.node_id.clone(),
19018 workspace_id: WorkspaceId::new("primary").unwrap(),
19019 worktree_id: None,
19020 },
19021 profile_id: defaults.profile_id.clone(),
19022 expected_profile_revision: defaults.revision.clone(),
19023 overrides: crate::protocol::SpawnOverrides {
19024 provider: crate::protocol::SpawnOverride::Set {
19025 value: agent("codex"),
19026 },
19027 approval_level: Some(level),
19028 network_allowlist: Some(allowlist.clone()),
19029 ..crate::protocol::SpawnOverrides::default()
19030 },
19031 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19032 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19033 "codex-net-refuse-{}",
19034 match level {
19035 ApprovalLevel::ReadOnly => "ro",
19036 ApprovalLevel::FullAuto => "fa",
19037 _ => "other",
19038 }
19039 ))
19040 .unwrap(),
19041 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19042 };
19043 let resolved = spec.resolve(&defaults).unwrap();
19044 let failure = shared
19045 .resolve_environment_profile(&resolved)
19046 .expect_err("RO/FullAuto must refuse");
19047 assert_eq!(
19048 failure.code,
19049 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19050 );
19051 }
19052 }
19053
19054 #[test]
19058 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19059 let (shared, _defaults) = station_knobs_fixture();
19060 let allowlist =
19061 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19062 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19063 id: allowlist.clone(),
19064 permits: vec![
19065 crate::network_allowlist_catalog::NetworkPermitSketch {
19066 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19067 peer: "github.com:443".to_owned(),
19068 },
19069 crate::network_allowlist_catalog::NetworkPermitSketch {
19070 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19071 peer: "*.npmjs.org".to_owned(),
19072 },
19073 ],
19074 provider_native: None,
19075 };
19076 shared
19077 .network_allowlist_catalog
19078 .write()
19079 .unwrap_or_else(|poisoned| poisoned.into_inner())
19080 .insert(entry)
19081 .unwrap();
19082
19083 let unique = std::time::SystemTime::now()
19084 .duration_since(std::time::UNIX_EPOCH)
19085 .unwrap()
19086 .as_nanos();
19087 let provider_home = std::env::temp_dir().join(format!(
19088 "gate4agent-claude-provider-home-{}-{unique}",
19089 std::process::id(),
19090 ));
19091 std::fs::create_dir_all(&provider_home).unwrap();
19092
19093 let overlay = shared
19094 .resolve_network_allowlist_overlay_args(
19095 &agent("claude"),
19096 SessionMode::Pty,
19097 ApprovalLevel::Moderate,
19098 Some(&allowlist),
19099 Some(provider_home.as_path()),
19100 )
19101 .expect("Claude PTY settings overlay on unix");
19102 let settings_path = provider_home.join(
19103 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19104 );
19105 assert_eq!(
19106 overlay,
19107 vec![
19108 std::ffi::OsString::from("--settings"),
19109 std::ffi::OsString::from(settings_path.as_os_str()),
19110 ]
19111 );
19112 let body = std::fs::read_to_string(&settings_path).unwrap();
19113 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19114 assert_eq!(value["sandbox"]["enabled"], true);
19115 assert_eq!(
19116 value["sandbox"]["network"]["allowedDomains"],
19117 serde_json::json!(["github.com", "*.npmjs.org"])
19118 );
19119 assert!(settings_path.starts_with(&provider_home));
19120
19121 let acp = shared
19122 .resolve_network_allowlist_overlay_args(
19123 &agent("claude"),
19124 SessionMode::Acp,
19125 ApprovalLevel::Moderate,
19126 Some(&allowlist),
19127 Some(provider_home.as_path()),
19128 )
19129 .expect_err("Claude ACP must refuse settings overlay");
19130 assert_eq!(
19131 acp.code,
19132 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19133 );
19134
19135 let no_home = shared
19136 .resolve_network_allowlist_overlay_args(
19137 &agent("claude"),
19138 SessionMode::Pty,
19139 ApprovalLevel::Moderate,
19140 Some(&allowlist),
19141 None,
19142 )
19143 .expect_err("Claude overlay requires ProviderHome");
19144 assert_eq!(
19145 no_home.code,
19146 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19147 );
19148
19149 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19150 shared
19151 .network_allowlist_catalog
19152 .write()
19153 .unwrap_or_else(|poisoned| poisoned.into_inner())
19154 .insert_id(bare.clone())
19155 .unwrap();
19156 let empty = shared
19157 .resolve_network_allowlist_overlay_args(
19158 &agent("claude"),
19159 SessionMode::Pty,
19160 ApprovalLevel::Moderate,
19161 Some(&bare),
19162 Some(provider_home.as_path()),
19163 )
19164 .unwrap();
19165 assert!(empty.is_empty());
19166
19167 let _ = std::fs::remove_dir_all(&provider_home);
19168 }
19169
19170 #[test]
19172 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19173 let (shared, _defaults) = station_knobs_fixture();
19174 let allowlist =
19175 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19176 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19177 id: allowlist.clone(),
19178 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19179 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19180 peer: "example.com:443".to_owned(),
19181 }],
19182 provider_native: None,
19183 };
19184 shared
19185 .network_allowlist_catalog
19186 .write()
19187 .unwrap_or_else(|poisoned| poisoned.into_inner())
19188 .insert(entry)
19189 .unwrap();
19190 for provider in ["kimi", "grok"] {
19191 let overlay = shared
19192 .resolve_network_allowlist_overlay_args(
19193 &agent(provider),
19194 SessionMode::Pty,
19195 ApprovalLevel::Moderate,
19196 Some(&allowlist),
19197 None,
19198 )
19199 .unwrap();
19200 assert!(
19201 overlay.is_empty(),
19202 "{provider} must not invent network argv from permits"
19203 );
19204 }
19205 }
19206
19207 #[test]
19209 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19210 let unique = std::time::SystemTime::now()
19211 .duration_since(std::time::UNIX_EPOCH)
19212 .unwrap()
19213 .as_nanos();
19214 let path = std::env::temp_dir().join(format!(
19215 "gate4agent-node-allowlist-json-{}-{unique}.json",
19216 std::process::id(),
19217 ));
19218 std::fs::write(
19219 &path,
19220 r#"{
19221 "schema_version": 2,
19222 "entries": [
19223 {
19224 "id": "egress-default",
19225 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19226 },
19227 { "id": "lab-egress" }
19228 ]
19229 }"#,
19230 )
19231 .unwrap();
19232 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19233 .expect("json v2 catalog must load");
19234 let _ = std::fs::remove_file(&path);
19235 assert_eq!(loaded.len(), 2);
19236 let entry = loaded
19237 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19238 .unwrap();
19239 assert_eq!(entry.permits.len(), 1);
19240 let (shared, defaults) = station_knobs_fixture();
19241 {
19242 let mut catalog = shared
19243 .network_allowlist_catalog
19244 .write()
19245 .unwrap_or_else(|poisoned| poisoned.into_inner());
19246 *catalog = loaded;
19247 }
19248 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19249 let spec = crate::protocol::SpawnSpec {
19250 target: crate::protocol::SpawnTarget {
19251 node_id: shared.node_id.clone(),
19252 workspace_id: WorkspaceId::new("primary").unwrap(),
19253 worktree_id: None,
19254 },
19255 profile_id: defaults.profile_id.clone(),
19256 expected_profile_revision: defaults.revision.clone(),
19257 overrides: crate::protocol::SpawnOverrides {
19258 network_allowlist: Some(allowlist.clone()),
19259 ..crate::protocol::SpawnOverrides::default()
19260 },
19261 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19262 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19263 "station-knobs-catalog-json",
19264 )
19265 .unwrap(),
19266 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19267 };
19268 let resolved = spec.resolve(&defaults).unwrap();
19269 let receipt = shared
19270 .resolve_environment_profile(&resolved)
19271 .expect("permits-only entry ok for claude")
19272 .expect("env profile present");
19273 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19274 let inventory = shared
19275 .snapshot()
19276 .launch_inventory
19277 .expect("launch inventory present");
19278 let ids = inventory.network_allowlists.expect("ids surface");
19279 assert_eq!(
19280 ids,
19281 vec![
19282 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19283 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19284 ]
19285 );
19286 }
19287
19288
19289}
19290
19291
19292#[cfg(test)]
19293mod workspace_environment_collision_tests {
19294 use super::*;
19295 use crate::session_environment::{
19296 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19297 };
19298 use gate4agent_catalog::EnvMutation;
19299 use gate4agent_runtime_native::{
19300 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19301 NativeLaunchProfileId,
19302 };
19303 use std::ffi::OsString;
19304 use std::sync::Arc;
19305
19306 struct EmptyEnvironmentResolver;
19307
19308 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19309 fn resolve_child_environment(
19310 &self,
19311 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19312 Ok(vec![EnvMutation {
19313 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19314 value: None,
19315 }])
19316 }
19317 }
19318
19319 fn agent(value: &str) -> AgentId {
19320 AgentId::new(value).unwrap()
19321 }
19322
19323 fn claude_config_dir_collision_fixture() -> (
19325 NodeShared,
19326 ResolvedEnvironmentProfileReceipt,
19327 ResolvedEnvironmentProfileReceipt,
19328 ) {
19329 let catalog = active_registry().unwrap();
19330 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19331 let control = runtime.native_launch_profile_control();
19332 let workspace = WorkspaceConfig::new(
19333 WorkspaceId::new("primary").unwrap(),
19334 std::env::current_dir().unwrap(),
19335 )
19336 .unwrap();
19337 let shared = NodeShared::new_with_incarnation(
19338 handle,
19339 "fixture-token".to_owned(),
19340 NodeId::new("node-env-collision-test").unwrap(),
19341 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19342 vec![workspace],
19343 vec![agent("claude")],
19344 ProviderRuntimeStatuses::default(),
19345 None,
19346 Vec::new(),
19347 Vec::new(),
19348 SpawnProfileRegistry::default(),
19349 Some(control),
19350 None,
19351 None,
19352 None,
19353 Vec::new(),
19354 Vec::new(),
19355 Vec::new(),
19356 Vec::new(),
19357 None,
19358 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19359 );
19360
19361 let mut receipts = Vec::new();
19362 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19363 let profile_id =
19364 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19365 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19366 "claude-config-dir-{suffix}-r1"
19367 ))
19368 .unwrap();
19369 let materialization = NodeSessionMaterializationProfile::new(
19370 Vec::new(),
19371 vec![NodeSessionPathBinding::new(
19372 "CLAUDE_CONFIG_DIR",
19373 NodeSessionPathClass::ProviderHome,
19374 )
19375 .unwrap()],
19376 Vec::new(),
19377 )
19378 .unwrap();
19379 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19380 profile_id.clone(),
19381 profile_revision.clone(),
19382 agent("claude"),
19383 [NativeLaunchProfile::new(
19384 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19385 .unwrap(),
19386 agent("claude"),
19387 TransportKind::Pty,
19388 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19389 Arc::new(EmptyEnvironmentResolver),
19390 )
19391 .unwrap()],
19392 Some(materialization),
19393 )
19394 .unwrap();
19395 let (binding, native_profiles, materialization) = node_profile.into_parts();
19396 for native_profile in native_profiles {
19397 runtime.upsert_native_launch_profile(native_profile).unwrap();
19398 }
19399 shared
19400 .environment_profiles
19401 .write()
19402 .unwrap_or_else(|poisoned| poisoned.into_inner())
19403 .insert(binding.id.clone(), binding);
19404 shared
19405 .environment_materialization_profiles
19406 .write()
19407 .unwrap_or_else(|poisoned| poisoned.into_inner())
19408 .insert(profile_id.clone(), materialization.unwrap());
19409 receipts.push(ResolvedEnvironmentProfileReceipt {
19410 profile_id,
19411 profile_revision,
19412 network_allowlist: None,
19413 browser_profile_id: None,
19414 });
19415 }
19416 let profile_b = receipts.pop().unwrap();
19417 let profile_a = receipts.pop().unwrap();
19418 (shared, profile_a, profile_b)
19419 }
19420
19421 #[test]
19422 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19423 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19424 let primary = WorkspaceId::new("primary").unwrap();
19425 let worktree = WorkspaceId::new("worktree-b").unwrap();
19426 let address = SessionAddress {
19427 workspace_id: primary.clone(),
19428 session: SessionKey {
19429 instance_id: AgentInstanceId(41),
19430 generation: SessionGeneration(1),
19431 },
19432 };
19433 shared.bind_session_with_policy(
19434 &address,
19435 ProviderRuntimePolicy::raw_pty(),
19436 Some(profile_a.clone()),
19437 );
19438
19439 let refused = shared
19440 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19441 .unwrap_err();
19442 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19443
19444 let refused_none = shared
19446 .ensure_workspace_environment_profile_compatible(&primary, None)
19447 .unwrap_err();
19448 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19449
19450 shared
19452 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19453 .unwrap();
19454
19455 shared
19457 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19458 .unwrap();
19459 }
19460
19461 #[tokio::test]
19462 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19463 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19464 let primary = WorkspaceId::new("primary").unwrap();
19465 let address = SessionAddress {
19466 workspace_id: primary.clone(),
19467 session: SessionKey {
19468 instance_id: AgentInstanceId(41),
19469 generation: SessionGeneration(1),
19470 },
19471 };
19472 shared.bind_session_with_policy(
19473 &address,
19474 ProviderRuntimePolicy::raw_pty(),
19475 Some(profile_a),
19476 );
19477
19478 let failure = shared
19479 .spawn_session_with_deadline(
19480 primary,
19481 agent("claude"),
19482 SessionMode::Pty,
19483 gate4agent_types::TerminalSize {
19484 rows: 24,
19485 columns: 80,
19486 },
19487 None,
19488 Some(profile_b),
19489 None,
19490 None,
19491 None,
19492 None,
19493 SpawnRecordPolicy::ProviderIdentityOnly,
19494 Some(Instant::now() + Duration::from_secs(30)),
19495 &[],
19496 None,
19497 ApprovalLevel::default(),
19498 None,
19499 None,
19500 )
19501 .await
19502 .unwrap_err();
19503 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19504 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19506 assert!(shared.handle.snapshot().sessions.is_empty());
19507 }
19508}
19509
19510#[cfg(test)]
19511mod spawn_characterization_tests {
19512 use super::*;
19513 use crate::session_environment::{
19514 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19515 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19516 NodeSessionPathClass,
19517 };
19518 use gate4agent_catalog::EnvMutation;
19519 use gate4agent_runtime_native::{
19520 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19521 NativeLaunchProfile, NativeLaunchProfileId,
19522 };
19523 use std::ffi::OsString;
19524 use std::sync::Arc;
19525
19526 struct EmptyEnvironmentResolver;
19527
19528 struct FixtureSecretResolver {
19529 deny: Arc<AtomicBool>,
19530 }
19531
19532 impl NodeSecretResolver for FixtureSecretResolver {
19533 fn resolve(
19534 &self,
19535 _reference: &NodeSecretReference,
19536 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19537 if self.deny.load(Ordering::Acquire) {
19538 Err(NodeSecretResolveError::Denied)
19539 } else {
19540 NodeSecretValue::text("fixture-secret")
19541 .map_err(|_| NodeSecretResolveError::Unavailable)
19542 }
19543 }
19544 }
19545
19546 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19547 fn resolve_child_environment(
19548 &self,
19549 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19550 Ok(vec![EnvMutation {
19551 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19552 value: None,
19553 }])
19554 }
19555 }
19556
19557 fn agent(value: &str) -> AgentId {
19558 AgentId::new(value).unwrap()
19559 }
19560
19561 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19562 let unique = std::time::SystemTime::now()
19563 .duration_since(std::time::UNIX_EPOCH)
19564 .unwrap()
19565 .as_nanos();
19566 std::env::temp_dir().join(format!(
19567 "gate4agent-node-{label}-{}-{unique}",
19568 std::process::id(),
19569 ))
19570 }
19571
19572 fn materialization_test_shared(
19573 deny_secret: bool,
19574 ) -> (
19575 NodeShared,
19576 NativeRuntime,
19577 ResolvedEnvironmentProfileReceipt,
19578 PathBuf,
19579 Arc<AtomicBool>,
19580 ) {
19581 let workspace = WorkspaceConfig::new(
19582 WorkspaceId::new("primary").unwrap(),
19583 std::env::current_dir().unwrap(),
19584 )
19585 .unwrap();
19586 materialization_test_shared_with_workspace(deny_secret, workspace)
19587 }
19588
19589 fn materialization_test_shared_with_workspace(
19590 deny_secret: bool,
19591 workspace: WorkspaceConfig,
19592 ) -> (
19593 NodeShared,
19594 NativeRuntime,
19595 ResolvedEnvironmentProfileReceipt,
19596 PathBuf,
19597 Arc<AtomicBool>,
19598 ) {
19599 let root = temporary_workspace_root(if deny_secret {
19600 "materialization-denied"
19601 } else {
19602 "materialization-ready"
19603 });
19604 std::fs::create_dir_all(&root).unwrap();
19605 let materialization_root = root.join("session-environments");
19606 let deny = Arc::new(AtomicBool::new(deny_secret));
19607 let resolver = Arc::new(FixtureSecretResolver {
19608 deny: Arc::clone(&deny),
19609 });
19610 let materializer = SessionEnvironmentMaterializer::new(
19611 materialization_root,
19612 resolver,
19613 )
19614 .unwrap();
19615 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19616 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19617 "materialized-claude-r1",
19618 )
19619 .unwrap();
19620 let materialization = NodeSessionMaterializationProfile::new(
19621 vec![NodeSessionEnvironmentMutation::SetSecret {
19622 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19623 reference: NodeSecretReference::new("fixture-token").unwrap(),
19624 }],
19625 vec![NodeSessionPathBinding::new(
19626 "GATE4AGENT_PROVIDER_HOME",
19627 NodeSessionPathClass::ProviderHome,
19628 )
19629 .unwrap()],
19630 vec![NodeSessionFile::secret(
19631 NodeSessionPathClass::Config,
19632 "auth/token",
19633 NodeSecretReference::new("fixture-token").unwrap(),
19634 )
19635 .unwrap()],
19636 )
19637 .unwrap();
19638 let catalog = active_registry().unwrap();
19639 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19640 let control = runtime.native_launch_profile_control();
19641 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19642 profile_id.clone(),
19643 profile_revision.clone(),
19644 agent("claude"),
19645 [NativeLaunchProfile::new(
19646 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19647 agent("claude"),
19648 TransportKind::Pty,
19649 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19650 Arc::new(EmptyEnvironmentResolver),
19651 )
19652 .unwrap()],
19653 Some(materialization),
19654 )
19655 .unwrap();
19656 let (binding, native_profiles, materialization) = node_profile.into_parts();
19657 for native_profile in native_profiles {
19658 runtime.upsert_native_launch_profile(native_profile).unwrap();
19659 }
19660 let shared = NodeShared::new_with_incarnation(
19661 handle,
19662 "fixture-token".to_owned(),
19663 NodeId::new("node-materialization-test").unwrap(),
19664 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19665 vec![workspace],
19666 vec![agent("claude")],
19667 ProviderRuntimeStatuses::default(),
19668 None,
19669 Vec::new(),
19670 Vec::new(),
19671 SpawnProfileRegistry::default(),
19672 Some(control),
19673 None,
19674 Some(materializer),
19675 None,
19676 Vec::new(),
19677 Vec::new(),
19678 Vec::new(),
19679 Vec::new(),
19680 None,
19681 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19682 );
19683 shared
19684 .environment_profiles
19685 .write()
19686 .unwrap_or_else(|poisoned| poisoned.into_inner())
19687 .insert(binding.id.clone(), binding);
19688 shared
19689 .environment_materialization_profiles
19690 .write()
19691 .unwrap_or_else(|poisoned| poisoned.into_inner())
19692 .insert(profile_id.clone(), materialization.unwrap());
19693 (
19694 shared,
19695 runtime,
19696 ResolvedEnvironmentProfileReceipt {
19697 profile_id,
19698 profile_revision,
19699 network_allowlist: None,
19700 browser_profile_id: None,
19701 },
19702 root,
19703 deny,
19704 )
19705 }
19706
19707
19708 async fn characterization_spawn(
19711 shared: &NodeShared,
19712 mode: SessionMode,
19713 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19714 required: &[ProviderRuntimeCapability],
19715 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19716 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19717 shared
19718 .spawn_session_with_deadline(
19719 WorkspaceId::new("primary").unwrap(),
19720 agent("claude"),
19721 mode,
19722 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19723 None,
19724 environment_profile,
19725 None,
19726 None,
19727 None,
19728 Some(ProviderRuntimePolicy::raw_pty()),
19729 SpawnRecordPolicy::ProviderIdentityOnly,
19730 Some(Instant::now() + Duration::from_secs(30)),
19731 required,
19732 harness_mcp,
19733 ApprovalLevel::default(),
19734 None,
19735 None,
19736 )
19737 .await
19738 }
19739
19740 fn assert_no_session_state(shared: &NodeShared) {
19741 assert!(shared.handle.snapshot().sessions.is_empty());
19742 assert!(shared
19743 .session_bindings
19744 .lock()
19745 .unwrap_or_else(|poisoned| poisoned.into_inner())
19746 .is_empty());
19747 assert!(shared
19748 .materializations
19749 .lock()
19750 .unwrap_or_else(|poisoned| poisoned.into_inner())
19751 .is_empty());
19752 }
19753
19754 #[tokio::test]
19755 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19756 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19757 let error = characterization_spawn(
19758 &shared,
19759 SessionMode::Pty,
19760 None,
19761 &[ProviderRuntimeCapability::SemanticReadiness],
19762 None,
19763 )
19764 .await
19765 .unwrap_err();
19766 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19767 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19768 assert_no_session_state(&shared);
19769 drop(shared);
19770 drop(runtime);
19771 std::fs::remove_dir_all(root).unwrap();
19772 }
19773
19774 #[tokio::test]
19775 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19776 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19777 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19778 .await
19779 .unwrap_err();
19780 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19781 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19782 assert_no_session_state(&shared);
19783 drop(shared);
19784 drop(runtime);
19785 std::fs::remove_dir_all(root).unwrap();
19786 }
19787
19788 #[test]
19789 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19790 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19791 let address = SessionAddress {
19792 workspace_id: WorkspaceId::new("primary").unwrap(),
19793 session: SessionKey {
19794 instance_id: AgentInstanceId(77),
19795 generation: SessionGeneration(1),
19796 },
19797 };
19798 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19799 let removed = shared.remove_binding(&address).expect("binding removed");
19800 assert!(removed.environment_profile.is_some());
19801 assert!(shared.remove_binding(&address).is_none());
19802 assert_no_session_state(&shared);
19803 drop(shared);
19804 drop(runtime);
19805 std::fs::remove_dir_all(root).unwrap();
19806 }
19807
19808 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19809 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19810 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19811 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19812 .expect("review current test executable as fixture helper");
19813 let registry = HarnessMcpProxyRegistry::new(helper);
19814 let spec = crate::protocol::SpawnSpec {
19815 target: crate::protocol::SpawnTarget {
19816 node_id: NodeId::new("node-characterization").unwrap(),
19817 workspace_id: WorkspaceId::new("primary").unwrap(),
19818 worktree_id: None,
19819 },
19820 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19821 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19822 overrides: crate::protocol::SpawnOverrides::default(),
19823 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19824 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19825 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19826 };
19827 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19828 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19829 let launch = HarnessMcpLaunchV1 {
19830 server_name: "fixture-harness".to_owned(),
19831 args: vec!["--session-proxy".to_owned()],
19832 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19833 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19834 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19835 trace: None,
19836 scrub_env: Vec::new(),
19837 };
19838 let expires = unix_time_ms().saturating_add(30_000);
19839 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19840 registry
19841 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19842 .unwrap()
19843 }
19844
19845 #[tokio::test]
19846 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19847 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19848 let prepared = characterization_prepared_harness("claude").await;
19849 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19850 .await
19851 .unwrap_err();
19852 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19854 let mismatched = characterization_prepared_harness("codex").await;
19855 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19856 .await
19857 .unwrap_err();
19858 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19859 assert_no_session_state(&shared);
19860 drop(shared);
19861 drop(runtime);
19862 std::fs::remove_dir_all(root).unwrap();
19863 }
19864
19865 #[tokio::test]
19866 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19867 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19868 shared.native_launch_profile_control = None;
19869 let prepared = characterization_prepared_harness("claude").await;
19870 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19871 .await
19872 .unwrap_err();
19873 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19874 assert_no_session_state(&shared);
19875 drop(shared);
19876 drop(runtime);
19877 std::fs::remove_dir_all(root).unwrap();
19878 }
19879}
19880
19881#[cfg(all(test, windows))]
19882mod tests {
19883 use super::*;
19884 use gate4agent_types::ProviderActivity;
19885 use crate::session_environment::{
19886 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19887 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19888 NodeSessionPathClass,
19889 };
19890 use gate4agent_catalog::EnvMutation;
19891 use gate4agent_runtime_native::{
19892 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19893 NativeLaunchProfile, NativeLaunchProfileId,
19894 };
19895 use std::ffi::OsString;
19896 use std::sync::Arc;
19897
19898 struct EmptyEnvironmentResolver;
19899
19900 struct FixtureSecretResolver {
19901 deny: Arc<AtomicBool>,
19902 }
19903
19904 impl NodeSecretResolver for FixtureSecretResolver {
19905 fn resolve(
19906 &self,
19907 _reference: &NodeSecretReference,
19908 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19909 if self.deny.load(Ordering::Acquire) {
19910 Err(NodeSecretResolveError::Denied)
19911 } else {
19912 NodeSecretValue::text("fixture-secret")
19913 .map_err(|_| NodeSecretResolveError::Unavailable)
19914 }
19915 }
19916 }
19917
19918 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19919 fn resolve_child_environment(
19920 &self,
19921 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19922 Ok(vec![EnvMutation {
19923 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19924 value: None,
19925 }])
19926 }
19927 }
19928
19929 fn agent(value: &str) -> AgentId {
19930 AgentId::new(value).unwrap()
19931 }
19932
19933 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19934 let catalog = active_registry().unwrap();
19935 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19936 let workspace = WorkspaceConfig::new(
19937 WorkspaceId::new("primary").unwrap(),
19938 std::env::current_dir().unwrap(),
19939 )
19940 .unwrap()
19941 .with_worktree_service_mode(mode);
19942 NodeShared::new(
19943 handle,
19944 "fixture-token".to_owned(),
19945 NodeId::new("node-terminal-test").unwrap(),
19946 vec![workspace],
19947 vec![agent("claude")],
19948 )
19949 }
19950
19951 fn terminal_test_shared() -> NodeShared {
19952 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19953 }
19954
19955 #[cfg(feature = "fixture")]
19956 #[test]
19957 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19958 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19959 assert_eq!(probe.latest_code(), None);
19960
19961 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19962 assert_eq!(
19963 probe.latest_code(),
19964 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19965 );
19966
19967 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19968 assert_eq!(
19969 probe.latest_code(),
19970 Some(NodeFailureCode::SpawnDeadlineExceeded),
19971 );
19972
19973 probe.record(None);
19974 assert_eq!(probe.latest_code(), None);
19975 }
19976
19977 #[test]
19978 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19979 let source = temporary_workspace_root("launch-inventory-source");
19980 let allocation = temporary_workspace_root("launch-inventory-allocation");
19981 std::fs::create_dir_all(&source).unwrap();
19982 std::fs::create_dir_all(&allocation).unwrap();
19983 let workspace_id = WorkspaceId::new("primary").unwrap();
19984 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19985 .unwrap()
19986 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19987 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19988 workspace = workspace
19989 .with_managed_worktree_profile(
19990 ManagedWorktreeProfile::new(
19991 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19992 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19993 &allocation,
19994 "gate4agent",
19995 "HEAD",
19996 ManagedWorktreeRetention::RemoveWhenReleased,
19997 )
19998 .unwrap(),
19999 )
20000 .unwrap();
20001 }
20002 let overflow = workspace.clone().with_managed_worktree_profile(
20003 ManagedWorktreeProfile::new(
20004 WorktreeProfileId::new("overflow").unwrap(),
20005 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20006 &allocation,
20007 "gate4agent",
20008 "HEAD",
20009 ManagedWorktreeRetention::Retain,
20010 )
20011 .unwrap(),
20012 );
20013 assert!(matches!(
20014 overflow,
20015 Err(NodeServerError::ManagedWorktreeProfileCapacity {
20016 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20017 ..
20018 }),
20019 ));
20020
20021 let catalog = active_registry().unwrap();
20022 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20023 let shared = NodeShared::new(
20024 handle,
20025 "fixture-token".to_owned(),
20026 NodeId::new("node-launch-inventory").unwrap(),
20027 vec![workspace],
20028 vec![agent("claude")],
20029 );
20030 let snapshot = shared.snapshot();
20031 let launch = snapshot.launch_inventory.as_ref().unwrap();
20032 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20033 assert!(launch.bundles.as_ref().unwrap().is_empty());
20034 let profiles = snapshot.workspaces[0]
20035 .managed_worktree_profiles
20036 .as_ref()
20037 .unwrap();
20038 assert_eq!(
20039 snapshot.workspaces[0].worktree_service_mode,
20040 Some(WorktreeServiceMode::Managed),
20041 );
20042 assert_eq!(
20043 profiles.profiles.len(),
20044 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20045 );
20046 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20047 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20048 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20049 }
20050 let legacy = snapshot_for_wire(
20051 &shared,
20052 true,
20053 true,
20054 false,
20055 false,
20056 true,
20057 false,
20058 true,
20059 false,
20060 true,
20061 );
20062 assert!(legacy.launch_inventory.is_none());
20063 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20064
20065 drop(shared);
20066 std::fs::remove_dir_all(source).unwrap();
20067 std::fs::remove_dir_all(allocation).unwrap();
20068 }
20069
20070 #[test]
20071 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20072 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20073 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20074 }));
20075 let source = gate4agent_types::ProviderSource {
20076 family: AdapterFamily::PtySemantic,
20077 binding: AdapterBinding::new(
20078 gate4agent_types::AdapterId::new("claude").unwrap(),
20079 "fixture/v1",
20080 gate4agent_types::AdapterVerification::SyntheticFixture,
20081 )
20082 .unwrap(),
20083 };
20084 let provider = ProviderSnapshot {
20085 sequence: 17,
20086 session: Some(ProviderSessionIdentity {
20087 key: ProviderSessionKey::SessionId,
20088 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20089 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20090 }),
20091 model: Some("PRIVATE_MODEL".to_owned()),
20092 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20093 completed_turns: 3,
20094 usage: gate4agent_types::TokenUsage {
20095 input_tokens: 10,
20096 output_tokens: 20,
20097 cache_read_tokens: 30,
20098 cache_write_tokens: 40,
20099 reasoning_tokens: 50,
20100 context_window: Some(200_000),
20101 },
20102 lead_activity: ProviderActivity::WaitingForInput,
20103 activity: ProviderActivity::WaitingForInput,
20104 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20105 active_tools: (0..9)
20106 .map(|index| gate4agent_types::ActiveProviderTool {
20107 id: format!("private-tool-id-{index}"),
20108 name: if index == 0 {
20109 format!(" Read\u{0000}{}", "x".repeat(80))
20110 } else {
20111 format!("Tool-{index}")
20112 },
20113 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20114 })
20115 .collect(),
20116 interactions: vec![gate4agent_types::ProviderInteraction {
20117 id: gate4agent_types::ProviderInteractionId(17),
20118 source: source.clone(),
20119 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20120 interaction_kind: ProviderInteractionKind::Approval,
20121 tool_name: " Approve\u{0007}Tool ".to_owned(),
20122 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20123 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20124 resume_lead_activity: Some(ProviderActivity::Working),
20125 status: ProviderInteractionStatus::Pending,
20126 }],
20127 subagents: vec![gate4agent_types::ProviderSubagent {
20128 source: source.clone(),
20129 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20130 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20131 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20132 }],
20133 sources: vec![gate4agent_types::ProviderSourceCursor {
20134 source,
20135 sequence: 17,
20136 gap_count: 2,
20137 stale: true,
20138 }],
20139 last_event: Some(ProviderEvent::Error {
20140 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20141 }),
20142 gap_count: 2,
20143 stale: true,
20144 };
20145 let address = terminal_address(3);
20146 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20147 assert_eq!(entry.address, address);
20148 assert_eq!(entry.progress.provider_sequence, 17);
20149 assert_eq!(entry.progress.active_tool_count, 9);
20150 assert_eq!(
20151 entry.progress.active_tool_labels.len(),
20152 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20153 );
20154 assert_eq!(entry.progress.subagent_count, 1);
20155 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20156 assert!(entry.progress.truncated);
20157 let encoded = serde_json::to_string(&entry).unwrap();
20158 for forbidden in [
20159 "PRIVATE_PROVIDER_SESSION",
20160 "PRIVATE_PROVIDER_PATH",
20161 "PRIVATE_MODEL",
20162 "PRIVATE_TOOL_INVENTORY",
20163 "PRIVATE_CURRENT_PROMPT",
20164 "PRIVATE_TOOL_INPUT_JSON",
20165 "PRIVATE_PROVIDER_REQUEST",
20166 "PRIVATE_INTERACTION_PROMPT",
20167 "PRIVATE_INTERACTION_AGENT",
20168 "PRIVATE_SUBAGENT_ID",
20169 "PRIVATE_SUBAGENT_TYPE",
20170 "PRIVATE_SUBAGENT_DESCRIPTION",
20171 "PRIVATE_PROVIDER_ERROR",
20172 "input_json",
20173 "current_prompt",
20174 "session_id",
20175 ] {
20176 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20177 }
20178
20179 let snapshot = NodeSnapshot {
20180 node_id: NodeId::new("node-progress-test").unwrap(),
20181 enabled_providers: vec![agent("claude")],
20182 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20183 workspaces: Vec::new(),
20184 session_records: Vec::new(),
20185 managed_worktrees: Vec::new(),
20186 launch_inventory: None,
20187 agent_progress: vec![entry],
20188 };
20189 let mut legacy_reply = ResponseEnvelope {
20190 request_id: 1,
20191 result: Ok(NodeResponse::Snapshot {
20192 event_sequence: 0,
20193 controller: None,
20194 snapshot: snapshot.clone(),
20195 }),
20196 };
20197 project_response_without_agent_progress(&mut legacy_reply);
20198 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20199 panic!("agent progress projection changed response kind");
20200 };
20201 assert!(legacy.agent_progress.is_empty());
20202 assert_eq!(snapshot.agent_progress.len(), 1);
20203 }
20204
20205 fn terminal_address(generation: u64) -> SessionAddress {
20206 SessionAddress {
20207 workspace_id: WorkspaceId::new("primary").unwrap(),
20208 session: SessionKey {
20209 instance_id: AgentInstanceId(41),
20210 generation: SessionGeneration(generation),
20211 },
20212 }
20213 }
20214
20215 fn spawn_spec_fixture() -> SpawnSpec {
20216 SpawnSpec {
20217 target: crate::protocol::SpawnTarget {
20218 node_id: NodeId::new("node-terminal-test").unwrap(),
20219 workspace_id: WorkspaceId::new("primary").unwrap(),
20220 worktree_id: None,
20221 },
20222 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20223 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20224 .unwrap(),
20225 overrides: crate::protocol::SpawnOverrides::default(),
20226 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20227 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20228 required_capabilities: SpawnRequiredCapabilities::default(),
20229 }
20230 }
20231
20232 fn environment_profile_test_shared(
20233 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20234 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20235 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20236 "local-claude-r1",
20237 )
20238 .unwrap();
20239 let spawn_profiles = SpawnProfileRegistry::new([
20240 crate::protocol::SpawnProfileDefaults {
20241 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20242 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20243 provider: agent("claude"),
20244 mode: SessionMode::Pty,
20245 terminal_size: gate4agent_types::TerminalSize {
20246 rows: 24,
20247 columns: 80,
20248 },
20249 prompt: None,
20250 bundle_id: None,
20251 context_id: None,
20252 environment_profile_id: Some(profile_id.clone()),
20253 },
20254 ])
20255 .unwrap();
20256 let catalog = active_registry().unwrap();
20257 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20258 let control = runtime.native_launch_profile_control();
20259 let node_profile = NodeEnvironmentProfile::new(
20260 profile_id.clone(),
20261 profile_revision.clone(),
20262 agent("claude"),
20263 [NativeLaunchProfile::new(
20264 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20265 agent("claude"),
20266 TransportKind::Pty,
20267 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20268 Arc::new(EmptyEnvironmentResolver),
20269 )
20270 .unwrap()],
20271 )
20272 .unwrap();
20273 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20274 for native_profile in native_profiles {
20275 runtime.upsert_native_launch_profile(native_profile).unwrap();
20276 }
20277 let workspace = WorkspaceConfig::new(
20278 WorkspaceId::new("primary").unwrap(),
20279 std::env::current_dir().unwrap(),
20280 )
20281 .unwrap();
20282 let shared = NodeShared::new_with_incarnation(
20283 handle,
20284 "fixture-token".to_owned(),
20285 NodeId::new("node-terminal-test").unwrap(),
20286 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20287 vec![workspace],
20288 vec![agent("claude")],
20289 ProviderRuntimeStatuses::default(),
20290 None,
20291 Vec::new(),
20292 Vec::new(),
20293 spawn_profiles,
20294 Some(control),
20295 None,
20296 None,
20297 None,
20298 Vec::new(),
20299 Vec::new(),
20300 Vec::new(),
20301 Vec::new(),
20302 None,
20303 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20304 );
20305 shared
20306 .environment_profiles
20307 .write()
20308 .unwrap_or_else(|poisoned| poisoned.into_inner())
20309 .insert(binding.id.clone(), binding);
20310 (
20311 shared,
20312 runtime,
20313 ResolvedEnvironmentProfileReceipt {
20314 profile_id,
20315 profile_revision,
20316 network_allowlist: None,
20317 browser_profile_id: None,
20318 },
20319 )
20320 }
20321
20322 fn materialization_test_shared(
20323 deny_secret: bool,
20324 ) -> (
20325 NodeShared,
20326 NativeRuntime,
20327 ResolvedEnvironmentProfileReceipt,
20328 PathBuf,
20329 Arc<AtomicBool>,
20330 ) {
20331 let workspace = WorkspaceConfig::new(
20332 WorkspaceId::new("primary").unwrap(),
20333 std::env::current_dir().unwrap(),
20334 )
20335 .unwrap();
20336 materialization_test_shared_with_workspace(deny_secret, workspace)
20337 }
20338
20339 fn materialization_test_shared_with_workspace(
20340 deny_secret: bool,
20341 workspace: WorkspaceConfig,
20342 ) -> (
20343 NodeShared,
20344 NativeRuntime,
20345 ResolvedEnvironmentProfileReceipt,
20346 PathBuf,
20347 Arc<AtomicBool>,
20348 ) {
20349 let root = temporary_workspace_root(if deny_secret {
20350 "materialization-denied"
20351 } else {
20352 "materialization-ready"
20353 });
20354 std::fs::create_dir_all(&root).unwrap();
20355 let materialization_root = root.join("session-environments");
20356 let deny = Arc::new(AtomicBool::new(deny_secret));
20357 let resolver = Arc::new(FixtureSecretResolver {
20358 deny: Arc::clone(&deny),
20359 });
20360 let materializer = SessionEnvironmentMaterializer::new(
20361 materialization_root,
20362 resolver,
20363 )
20364 .unwrap();
20365 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20366 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20367 "materialized-claude-r1",
20368 )
20369 .unwrap();
20370 let materialization = NodeSessionMaterializationProfile::new(
20371 vec![NodeSessionEnvironmentMutation::SetSecret {
20372 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20373 reference: NodeSecretReference::new("fixture-token").unwrap(),
20374 }],
20375 vec![NodeSessionPathBinding::new(
20376 "GATE4AGENT_PROVIDER_HOME",
20377 NodeSessionPathClass::ProviderHome,
20378 )
20379 .unwrap()],
20380 vec![NodeSessionFile::secret(
20381 NodeSessionPathClass::Config,
20382 "auth/token",
20383 NodeSecretReference::new("fixture-token").unwrap(),
20384 )
20385 .unwrap()],
20386 )
20387 .unwrap();
20388 let catalog = active_registry().unwrap();
20389 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20390 let control = runtime.native_launch_profile_control();
20391 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20392 profile_id.clone(),
20393 profile_revision.clone(),
20394 agent("claude"),
20395 [NativeLaunchProfile::new(
20396 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20397 agent("claude"),
20398 TransportKind::Pty,
20399 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20400 Arc::new(EmptyEnvironmentResolver),
20401 )
20402 .unwrap()],
20403 Some(materialization),
20404 )
20405 .unwrap();
20406 let (binding, native_profiles, materialization) = node_profile.into_parts();
20407 for native_profile in native_profiles {
20408 runtime.upsert_native_launch_profile(native_profile).unwrap();
20409 }
20410 let shared = NodeShared::new_with_incarnation(
20411 handle,
20412 "fixture-token".to_owned(),
20413 NodeId::new("node-materialization-test").unwrap(),
20414 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20415 vec![workspace],
20416 vec![agent("claude")],
20417 ProviderRuntimeStatuses::default(),
20418 None,
20419 Vec::new(),
20420 Vec::new(),
20421 SpawnProfileRegistry::default(),
20422 Some(control),
20423 None,
20424 Some(materializer),
20425 None,
20426 Vec::new(),
20427 Vec::new(),
20428 Vec::new(),
20429 Vec::new(),
20430 None,
20431 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20432 );
20433 shared
20434 .environment_profiles
20435 .write()
20436 .unwrap_or_else(|poisoned| poisoned.into_inner())
20437 .insert(binding.id.clone(), binding);
20438 shared
20439 .environment_materialization_profiles
20440 .write()
20441 .unwrap_or_else(|poisoned| poisoned.into_inner())
20442 .insert(profile_id.clone(), materialization.unwrap());
20443 (
20444 shared,
20445 runtime,
20446 ResolvedEnvironmentProfileReceipt {
20447 profile_id,
20448 profile_revision,
20449 network_allowlist: None,
20450 browser_profile_id: None,
20451 },
20452 root,
20453 deny,
20454 )
20455 }
20456
20457 #[cfg(feature = "fixture")]
20458 #[derive(Clone, Copy)]
20459 enum CodexHomeProfile {
20460 Missing,
20461 WrongClass,
20462 Exact,
20463 }
20464
20465 #[cfg(feature = "fixture")]
20466 fn codex_bundle_test_shared(
20467 home_profile: CodexHomeProfile,
20468 ) -> (
20469 NodeShared,
20470 NativeRuntime,
20471 ResolvedEnvironmentProfileReceipt,
20472 NodeBundle,
20473 PathBuf,
20474 ) {
20475 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20476 const CLAUDE_MANIFEST: &[u8] =
20477 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20478 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20479 const DIGEST: &str =
20480 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20481
20482 let (shared, mut runtime, _claude_receipt, root, _deny) =
20483 materialization_test_shared(false);
20484 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20485 CodexHomeProfile::Missing => "codex-home-missing",
20486 CodexHomeProfile::WrongClass => "codex-home-wrong",
20487 CodexHomeProfile::Exact => "codex-home-exact",
20488 })
20489 .unwrap();
20490 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20491 "codex-home-r1",
20492 )
20493 .unwrap();
20494 let materialization = match home_profile {
20495 CodexHomeProfile::Missing => None,
20496 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20497 Some(NodeSessionMaterializationProfile::new(
20498 Vec::new(),
20499 vec![NodeSessionPathBinding::new(
20500 "CODEX_HOME",
20501 if matches!(home_profile, CodexHomeProfile::Exact) {
20502 NodeSessionPathClass::ProviderHome
20503 } else {
20504 NodeSessionPathClass::Config
20505 },
20506 )
20507 .unwrap()],
20508 Vec::new(),
20509 )
20510 .unwrap())
20511 }
20512 };
20513 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20514 profile_id.clone(),
20515 profile_revision.clone(),
20516 agent("codex"),
20517 [NativeLaunchProfile::new(
20518 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20519 agent("codex"),
20520 TransportKind::Pty,
20521 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20522 Arc::new(EmptyEnvironmentResolver),
20523 )
20524 .unwrap()],
20525 materialization,
20526 )
20527 .unwrap();
20528 let (binding, native_profiles, materialization) = node_profile.into_parts();
20529 for native_profile in native_profiles {
20530 runtime.upsert_native_launch_profile(native_profile).unwrap();
20531 }
20532 shared
20533 .environment_profiles
20534 .write()
20535 .unwrap_or_else(|poisoned| poisoned.into_inner())
20536 .insert(binding.id.clone(), binding);
20537 if let Some(materialization) = materialization {
20538 shared
20539 .environment_materialization_profiles
20540 .write()
20541 .unwrap_or_else(|poisoned| poisoned.into_inner())
20542 .insert(profile_id.clone(), materialization);
20543 }
20544
20545 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20546 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20547 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20548 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20549 std::fs::write(
20550 bundle_source.join(".claude-plugin/plugin.json"),
20551 CLAUDE_MANIFEST,
20552 )
20553 .unwrap();
20554 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20555 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20556 let bundle = NodeBundle::new(
20557 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20558 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20559 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20560 &bundle_source,
20561 )
20562 .unwrap();
20563 *shared
20564 .bundle_catalog
20565 .write()
20566 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20567 BundleCatalog::new([bundle.clone()]).unwrap();
20568 (
20569 shared,
20570 runtime,
20571 ResolvedEnvironmentProfileReceipt {
20572 profile_id,
20573 profile_revision,
20574 network_allowlist: None,
20575 browser_profile_id: None,
20576 },
20577 bundle,
20578 root,
20579 )
20580 }
20581
20582 #[cfg(feature = "fixture")]
20583 #[test]
20584 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20585 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20586 let (shared, runtime, receipt, bundle, root) =
20587 codex_bundle_test_shared(home_profile);
20588 let error = shared
20589 .bundle_layout(
20590 &agent("codex"),
20591 SessionMode::Pty,
20592 Some(&receipt),
20593 &bundle,
20594 )
20595 .unwrap_err();
20596 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20597 assert!(shared
20598 .materializations
20599 .lock()
20600 .unwrap_or_else(|poisoned| poisoned.into_inner())
20601 .is_empty());
20602 let materialization_root = root.join("session-environments");
20603 assert!(!std::fs::read_dir(&materialization_root)
20604 .unwrap()
20605 .filter_map(Result::ok)
20606 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20607 drop(shared);
20608 drop(runtime);
20609 let _ = std::fs::remove_dir_all(root);
20610 }
20611 }
20612
20613 #[cfg(feature = "fixture")]
20614 #[test]
20615 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20616 let (shared, runtime, receipt, bundle, root) =
20617 codex_bundle_test_shared(CodexHomeProfile::Exact);
20618 let address = SessionAddress {
20619 workspace_id: WorkspaceId::new("primary").unwrap(),
20620 session: SessionKey {
20621 instance_id: AgentInstanceId(310),
20622 generation: SessionGeneration(1),
20623 },
20624 };
20625 let (overlay, guard) = shared
20626 .prepare_session_materialization(
20627 &address,
20628 &agent("codex"),
20629 SessionMode::Pty,
20630 Some(&receipt),
20631 Some(&bundle.receipt()),
20632 None,
20633 None,
20634 )
20635 .unwrap()
20636 .unwrap();
20637 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20638 let ownership = shared
20639 .materializations
20640 .lock()
20641 .unwrap_or_else(|poisoned| poisoned.into_inner())
20642 .get(guard.id().unwrap())
20643 .unwrap()
20644 .clone();
20645 let (profile, _, _) = shared
20646 .materialization_profile(
20647 &agent("codex"),
20648 SessionMode::Pty,
20649 Some(&receipt),
20650 Some(&bundle.receipt()),
20651 None,
20652 )
20653 .unwrap()
20654 .unwrap();
20655 let environment = shared
20656 .session_environment_materializer
20657 .as_ref()
20658 .unwrap()
20659 .resolve_environment(&ownership, &profile)
20660 .unwrap();
20661 assert!(environment.iter().any(|mutation| {
20662 mutation.key == OsString::from("CODEX_HOME")
20663 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20664 }));
20665 assert_eq!(
20666 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20667 .unwrap(),
20668 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20669 );
20670 assert!(std::fs::read_dir(ownership.bundle_root())
20671 .unwrap()
20672 .next()
20673 .is_none());
20674 drop(guard);
20675 drop(shared);
20676 drop(runtime);
20677 let _ = std::fs::remove_dir_all(root);
20678 }
20679
20680 #[cfg(feature = "fixture")]
20681 #[test]
20682 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20683 let (shared, runtime, receipt, bundle, root) =
20684 codex_bundle_test_shared(CodexHomeProfile::Exact);
20685 let bundle_receipt = bundle.receipt();
20686 let address = SessionAddress {
20687 workspace_id: WorkspaceId::new("primary").unwrap(),
20688 session: SessionKey {
20689 instance_id: AgentInstanceId(311),
20690 generation: SessionGeneration(1),
20691 },
20692 };
20693 let (_overlay, guard) = shared
20694 .prepare_session_materialization(
20695 &address,
20696 &agent("codex"),
20697 SessionMode::Pty,
20698 Some(&receipt),
20699 Some(&bundle_receipt),
20700 None,
20701 None,
20702 )
20703 .unwrap()
20704 .unwrap();
20705 let materialization_id = guard.id().unwrap().clone();
20706 let skill_path = shared
20707 .materializations
20708 .lock()
20709 .unwrap_or_else(|poisoned| poisoned.into_inner())
20710 .get(&materialization_id)
20711 .unwrap()
20712 .provider_home()
20713 .join("skills/review-code/SKILL.md");
20714 let record_id = shared
20715 .bind_spawn_session_with_materialization(
20716 &address,
20717 agent("codex"),
20718 SessionMode::Pty,
20719 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20720 SpawnRecordPolicy::ProviderIdentityOnly,
20721 Some(receipt.clone()),
20722 Some(bundle_receipt.clone()),
20723 None,
20724 Some(materialization_id.clone()),
20725 )
20726 .unwrap()
20727 .unwrap();
20728 guard.retain();
20729 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20730
20731 let error = match shared.resolve_record_materialization(
20732 &record_id,
20733 &agent("codex"),
20734 SessionMode::Pty,
20735 Some(&receipt),
20736 Some(&bundle_receipt),
20737 None,
20738 ) {
20739 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20740 Err(error) => error,
20741 };
20742 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20743 assert_eq!(
20744 shared
20745 .materializations
20746 .lock()
20747 .unwrap_or_else(|poisoned| poisoned.into_inner())
20748 .get(&materialization_id)
20749 .unwrap()
20750 .state(),
20751 MaterializationState::RecoveryRequired,
20752 );
20753 drop(shared);
20754 drop(runtime);
20755 let _ = std::fs::remove_dir_all(root);
20756 }
20757
20758 fn bind_record_owned_materialization_fixture(
20759 shared: &NodeShared,
20760 receipt: &ResolvedEnvironmentProfileReceipt,
20761 instance_id: u64,
20762 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20763 let address = SessionAddress {
20764 workspace_id: WorkspaceId::new("primary").unwrap(),
20765 session: SessionKey {
20766 instance_id: AgentInstanceId(instance_id),
20767 generation: SessionGeneration(1),
20768 },
20769 };
20770 let (overlay, materialization_guard) = shared
20771 .prepare_session_materialization(
20772 &address,
20773 &agent("claude"),
20774 SessionMode::Pty,
20775 Some(receipt),
20776 None,
20777 None,
20778 None,
20779 )
20780 .unwrap()
20781 .unwrap();
20782 let materialization_id = materialization_guard.id().unwrap().clone();
20783 let materialization_root = shared
20784 .materializations
20785 .lock()
20786 .unwrap_or_else(|poisoned| poisoned.into_inner())
20787 .get(&materialization_id)
20788 .unwrap()
20789 .root()
20790 .to_path_buf();
20791 let selection = shared
20792 .select_environment_profile(
20793 address.session.instance_id,
20794 &agent("claude"),
20795 SessionMode::Pty,
20796 Some(receipt),
20797 )
20798 .unwrap()
20799 .unwrap();
20800 assert!(shared
20801 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20802 .unwrap()
20803 .is_none());
20804 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20805 let record_id = shared
20806 .bind_spawn_session_with_materialization(
20807 &address,
20808 agent("claude"),
20809 SessionMode::Pty,
20810 policy,
20811 SpawnRecordPolicy::ProviderIdentityOnly,
20812 Some(receipt.clone()),
20813 None,
20814 None,
20815 Some(materialization_id.clone()),
20816 )
20817 .unwrap()
20818 .unwrap();
20819 selection.retain();
20820 materialization_guard.retain();
20821 (address, record_id, materialization_id, materialization_root)
20822 }
20823
20824 #[test]
20825 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20826 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20827 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20828 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20829 }));
20830 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20831 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20832 let mut spec = spawn_spec_fixture();
20833 spec.expected_profile_revision =
20834 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20835 assert!(matches!(
20836 &spec.overrides.environment_profile_id,
20837 crate::protocol::SpawnOverride::Inherit,
20838 ));
20839 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20840 .requires_child_environment_profile_capability());
20841 assert!(request_requires_child_environment_profile(
20842 &shared,
20843 &NodeRequest::SpawnSpec { spec: spec.clone() },
20844 ));
20845
20846 let address = terminal_address(1);
20847 shared.bind_session_with_policy(
20848 &address,
20849 ProviderRuntimePolicy::raw_pty(),
20850 Some(environment_profile.clone()),
20851 );
20852 assert!(request_requires_child_environment_profile(
20853 &shared,
20854 &NodeRequest::Input {
20855 session: address.clone(),
20856 text: "x".to_owned(),
20857 },
20858 ));
20859
20860 let mut record = record("claude", "profile-record");
20861 record.active_session = Some(address.clone());
20862 record.environment_profile = Some(environment_profile.clone());
20863 let record_id = record.record_id.clone();
20864 shared.insert_record(record.clone()).unwrap();
20865 assert!(request_requires_child_environment_profile(
20866 &shared,
20867 &NodeRequest::ResumeSessionRecord {
20868 record_id,
20869 terminal_size: gate4agent_types::TerminalSize {
20870 rows: 24,
20871 columns: 80,
20872 },
20873 initial_prompt: None,
20874 },
20875 ));
20876
20877 let snapshot = snapshot_for_wire(
20878 &shared, false, true, true, true, false, false, false, false, true,
20879 );
20880 assert_eq!(snapshot.session_records.len(), 1);
20881 assert!(snapshot.session_records[0].environment_profile.is_none());
20882 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20883 sequence: 1,
20884 event: NodeEvent::SessionRecordUpserted { record },
20885 });
20886 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20887 panic!("record projection changed the event kind");
20888 };
20889 assert!(record.environment_profile.is_none());
20890
20891 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20892 let mut reply = ResponseEnvelope {
20893 request_id: 1,
20894 result: Ok(NodeResponse::SpawnSpecAccepted {
20895 receipt: resolved.receipt_with_environment(
20896 shared.incarnation_id,
20897 address,
20898 Some(environment_profile),
20899 ),
20900 }),
20901 };
20902 project_response_without_child_environment_profile(&mut reply);
20903 assert_eq!(
20904 reply.result.unwrap_err().code,
20905 NodeFailureCode::UnsupportedCapability,
20906 );
20907 }
20908
20909 #[test]
20910 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20911 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20912 let snapshot = shared.snapshot();
20913 let profiles = snapshot
20914 .launch_inventory
20915 .as_ref()
20916 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20917 .unwrap();
20918 assert_eq!(profiles.len(), 1);
20919 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20920 assert!(snapshot.requires_child_environment_profile_capability());
20921
20922 let mut legacy = snapshot.clone();
20923 clear_snapshot_child_environment_profiles(&mut legacy);
20924 assert!(legacy
20925 .launch_inventory
20926 .as_ref()
20927 .unwrap()
20928 .spawn_profiles
20929 .as_ref()
20930 .unwrap()[0]
20931 .environment_profile
20932 .is_none());
20933 assert!(!legacy.requires_child_environment_profile_capability());
20934
20935 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20936 let bindings = shared
20937 .environment_profiles
20938 .read()
20939 .unwrap_or_else(|poisoned| poisoned.into_inner())
20940 .clone();
20941 let mut provider_mismatch = bindings.clone();
20942 provider_mismatch
20943 .values_mut()
20944 .next()
20945 .unwrap()
20946 .provider = agent("codex");
20947 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20948 let mut mode_mismatch = profile;
20949 mode_mismatch.mode = SessionMode::Inline;
20950 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20951
20952 shared
20953 .environment_profiles
20954 .write()
20955 .unwrap_or_else(|poisoned| poisoned.into_inner())
20956 .clear();
20957 assert!(shared
20958 .snapshot()
20959 .launch_inventory
20960 .unwrap()
20961 .spawn_profiles
20962 .unwrap()
20963 .is_empty());
20964 }
20965
20966 #[test]
20967 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20968 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20969 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20970 }));
20971 let shared = terminal_test_shared();
20972 let bundle = ResolvedBundleReceipt {
20973 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20974 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20975 digest: crate::protocol::SpawnBundleDigest::new(format!(
20976 "sha256:{}",
20977 "0".repeat(64),
20978 ))
20979 .unwrap(),
20980 };
20981 let mut bundled = record("claude", "bundle-record");
20982 bundled.bundle = Some(bundle.clone());
20983 let record_id = bundled.record_id.clone();
20984 shared.insert_record(bundled.clone()).unwrap();
20985 assert!(request_requires_session_bundle(
20986 &shared,
20987 &NodeRequest::ForgetSessionRecord {
20988 record_id: record_id.clone(),
20989 },
20990 ));
20991
20992 let snapshot = snapshot_for_wire(
20993 &shared, false, true, true, true, true, false, false, false, true,
20994 );
20995 assert!(snapshot.session_records[0].bundle.is_none());
20996 let launch = snapshot.launch_inventory.as_ref().unwrap();
20997 assert!(launch.spawn_profiles.is_some());
20998 assert!(launch.bundles.is_none());
20999 let event = project_event_without_session_bundle(NodeEventEnvelope {
21000 sequence: 1,
21001 event: NodeEvent::SessionRecordUpserted { record: bundled },
21002 });
21003 let NodeEvent::SessionRecordUpserted { record } = event.event else {
21004 panic!("record projection changed the event kind");
21005 };
21006 assert!(record.bundle.is_none());
21007
21008 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21009 let mut reply = ResponseEnvelope {
21010 request_id: 1,
21011 result: Ok(NodeResponse::SpawnSpecAccepted {
21012 receipt: resolved.receipt_with_materialization(
21013 shared.incarnation_id,
21014 terminal_address(17),
21015 None,
21016 Some(bundle),
21017 None,
21018 ),
21019 }),
21020 };
21021 project_response_without_session_bundle(&mut reply);
21022 assert_eq!(
21023 reply.result.unwrap_err().code,
21024 NodeFailureCode::UnsupportedCapability,
21025 );
21026 }
21027
21028 #[test]
21029 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21030 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21031 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21032 }));
21033 let context_id = SpawnContextId::new("context-review").unwrap();
21034 let context = ResolvedContextPackReceipt {
21035 id: context_id.clone(),
21036 digest: crate::protocol::SpawnContextDigest::new(format!(
21037 "sha256:{}",
21038 "c".repeat(64),
21039 ))
21040 .unwrap(),
21041 lineage: ContextPackLineageReceipt {
21042 source_node_id: NodeId::new("node-source").unwrap(),
21043 source_session: terminal_address(1),
21044 source_provider: agent("kimi"),
21045 },
21046 source_message_count: 2,
21047 retained_message_count: 2,
21048 byte_len: 256,
21049 truncated: false,
21050 };
21051 assert!(context.is_valid());
21052 let mut bound_record = record("codex", "context-record");
21053 bound_record.context_id = Some(context_id);
21054 bound_record.context = Some(context.clone());
21055 let session = gate4agent_types::SessionSnapshot {
21056 instance_id: AgentInstanceId(91),
21057 agent_id: agent("kimi"),
21058 transport: TransportKind::Pty,
21059 generation: SessionGeneration(3),
21060 status: SessionStatus::Running,
21061 pending_operation: None,
21062 pending_input: None,
21063 process_id: Some(9001),
21064 terminal_size: None,
21065 terminal_frame: None,
21066 terminal_stale: None,
21067 session_options: None,
21068 capabilities: gate4agent_types::CapabilitySnapshot::default(),
21069 history: gate4agent_types::HistorySnapshot {
21070 pending: None,
21071 candidates: vec![HistoryCandidateSummary {
21072 id: "candidate-opaque".to_owned(),
21073 session_id_hint: "session-hint".to_owned(),
21074 modified_at_unix_ms: Some(1),
21075 }],
21076 loaded_candidate_id: Some("candidate-opaque".to_owned()),
21077 loaded: Some(HistorySessionRecord {
21078 session_id: "private-vendor-session".to_owned(),
21079 title: Some("private title".to_owned()),
21080 cwd: Some(r"C:\private\history-root".to_owned()),
21081 model: Some("private-model".to_owned()),
21082 message_count: 2,
21083 completed_turn_count: None,
21084 total_tokens: 19,
21085 messages: vec![
21086 gate4agent_types::HistoryMessageRecord {
21087 role: gate4agent_types::HistoryMessageRole::User,
21088 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21089 },
21090 gate4agent_types::HistoryMessageRecord {
21091 role: gate4agent_types::HistoryMessageRole::Assistant,
21092 text: "private answer".to_owned(),
21093 },
21094 ],
21095 }),
21096 last_error: None,
21097 },
21098 resume: gate4agent_types::ResumeSnapshot::default(),
21099 foreground: gate4agent_types::ForegroundSnapshot::default(),
21100 provider: gate4agent_types::ProviderSnapshot::default(),
21101 screen_state: gate4agent_types::PtyScreenState::default(),
21102 };
21103 let original = NodeSnapshot {
21104 node_id: NodeId::new("node-terminal-test").unwrap(),
21105 enabled_providers: vec![agent("kimi"), agent("codex")],
21106 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21107 workspaces: vec![WorkspaceSnapshot {
21108 workspace_id: WorkspaceId::new("primary").unwrap(),
21109 canonical_root: opaque_windows_path(
21110 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21111 ),
21112 sessions: vec![session],
21113 worktree_service_mode: None,
21114 managed_worktree_profiles: None,
21115 }],
21116 session_records: vec![bound_record],
21117 managed_worktrees: Vec::new(),
21118 launch_inventory: None,
21119 agent_progress: Vec::new(),
21120 };
21121
21122 let mut negotiated = original.clone();
21123 project_snapshot_history_for_wire(&mut negotiated, true);
21124 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21125 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21126 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21127 assert!(!serde_json::to_string(&negotiated)
21128 .unwrap()
21129 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21130
21131 let mut legacy = original;
21132 project_snapshot_history_for_wire(&mut legacy, false);
21133 clear_snapshot_context_packs(&mut legacy);
21134 assert_eq!(
21135 legacy.workspaces[0].sessions[0].history,
21136 gate4agent_types::HistorySnapshot::default(),
21137 );
21138 assert!(legacy.session_records[0].context_id.is_none());
21139 assert!(legacy.session_records[0].context.is_none());
21140 let encoded = serde_json::to_string(&legacy).unwrap();
21141 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21142 assert!(!encoded.contains("context-review"));
21143
21144 let mut reply = ResponseEnvelope {
21145 request_id: 7,
21146 result: Ok(NodeResponse::ContextPackExported { context }),
21147 };
21148 project_response_without_context_pack(&mut reply);
21149 assert_eq!(
21150 reply.result.unwrap_err().code,
21151 NodeFailureCode::UnsupportedCapability,
21152 );
21153 }
21154
21155 #[test]
21156 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21157 let shared = terminal_test_shared();
21158 let history = HistorySessionRecord {
21159 session_id: "fixture-history".to_owned(),
21160 title: None,
21161 cwd: None,
21162 model: None,
21163 message_count: 1,
21164 completed_turn_count: None,
21165 total_tokens: 0,
21166 messages: vec![gate4agent_types::HistoryMessageRecord {
21167 role: gate4agent_types::HistoryMessageRole::User,
21168 text: "bounded handoff".to_owned(),
21169 }],
21170 };
21171 let pack = NodeContextPack::export(
21172 ContextPackLineageReceipt {
21173 source_node_id: shared.node_id.clone(),
21174 source_session: terminal_address(1),
21175 source_provider: agent("claude"),
21176 },
21177 &history,
21178 )
21179 .unwrap();
21180 let context = shared
21181 .context_catalog
21182 .write()
21183 .unwrap_or_else(|poisoned| poisoned.into_inner())
21184 .insert(pack)
21185 .unwrap();
21186 let mut retained = record("codex", "context-retained-record");
21187 retained.context_id = Some(context.id.clone());
21188 retained.context = Some(context.clone());
21189 let record_id = retained.record_id.clone();
21190 shared.insert_record(retained).unwrap();
21191 assert_eq!(
21192 shared.forget_context_pack(&context.id).unwrap_err().code,
21193 NodeFailureCode::ContextPackBusy,
21194 );
21195 assert!(shared
21196 .context_catalog
21197 .read()
21198 .unwrap_or_else(|poisoned| poisoned.into_inner())
21199 .get(&context.id)
21200 .is_some());
21201 shared
21202 .session_records
21203 .lock()
21204 .unwrap_or_else(|poisoned| poisoned.into_inner())
21205 .records
21206 .remove(&record_id);
21207
21208 let mut spec = spawn_spec_fixture();
21209 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21210 value: context.id.clone(),
21211 };
21212 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21213 let receipt = resolved.receipt_with_materialization(
21214 shared.incarnation_id,
21215 terminal_address(2),
21216 None,
21217 None,
21218 Some(context.clone()),
21219 );
21220 assert!(receipt.context_binding_is_valid());
21221 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21222
21223 shared.forget_context_pack(&context.id).unwrap();
21224 assert!(shared
21225 .context_catalog
21226 .read()
21227 .unwrap_or_else(|poisoned| poisoned.into_inner())
21228 .get(&context.id)
21229 .is_none());
21230 assert!(shared
21231 .replay_spawn_spec(&spec, Instant::now())
21232 .unwrap()
21233 .is_none());
21234 }
21235
21236 #[test]
21237 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21238 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21239 let address = terminal_address(1);
21240 let selection = shared
21241 .select_environment_profile(
21242 address.session.instance_id,
21243 &agent("claude"),
21244 SessionMode::Pty,
21245 Some(&environment_profile),
21246 )
21247 .unwrap()
21248 .unwrap();
21249 shared.bind_session_with_policy(
21250 &address,
21251 ProviderRuntimePolicy::raw_pty(),
21252 Some(environment_profile.clone()),
21253 );
21254 selection.retain();
21255 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21256 assert!(matches!(
21257 runtime.remove_native_launch_profile(&native_id),
21258 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21259 ));
21260 assert!(shared.remove_binding(&address).is_some());
21261 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21262
21263 let mismatched = ResolvedEnvironmentProfileReceipt {
21264 profile_id: environment_profile.profile_id,
21265 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21266 "local-claude-r2",
21267 )
21268 .unwrap(),
21269 network_allowlist: None,
21270 browser_profile_id: None,
21271 };
21272 let error = match shared.select_environment_profile(
21273 AgentInstanceId(42),
21274 &agent("claude"),
21275 SessionMode::Pty,
21276 Some(&mismatched),
21277 ) {
21278 Ok(_) => panic!("mismatched environment profile revision was selected"),
21279 Err(error) => error,
21280 };
21281 assert_eq!(
21282 error.code,
21283 NodeFailureCode::EnvironmentProfileBindingMismatch,
21284 );
21285 assert!(!shared
21286 .session_bindings
21287 .lock()
21288 .unwrap_or_else(|poisoned| poisoned.into_inner())
21289 .contains_key(&AgentInstanceId(42)));
21290 }
21291
21292 #[test]
21293 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21294 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21295 let address = terminal_address(1);
21296 let selection = shared
21297 .select_environment_profile(
21298 address.session.instance_id,
21299 &agent("claude"),
21300 SessionMode::Pty,
21301 Some(&environment_profile),
21302 )
21303 .unwrap()
21304 .unwrap();
21305 let overlay = NativeInstanceLaunchOverlay::new(
21306 agent("claude"),
21307 TransportKind::Pty,
21308 vec![EnvMutation {
21309 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21310 value: Some(OsString::from("fixture-secret")),
21311 }],
21312 vec![
21313 OsString::from("--plugin-dir"),
21314 std::env::current_dir().unwrap().into_os_string(),
21315 ],
21316 )
21317 .unwrap();
21318 let instance_overlay = shared
21319 .install_prepared_launch_overlay(
21320 address.session.instance_id,
21321 PreparedNativeLaunchOverlay::Instance(overlay),
21322 )
21323 .unwrap()
21324 .unwrap();
21325 let bundle = ResolvedBundleReceipt {
21326 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21327 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21328 digest: crate::protocol::SpawnBundleDigest::new(format!(
21329 "sha256:{}",
21330 "0".repeat(64),
21331 ))
21332 .unwrap(),
21333 };
21334 shared.bind_session_with_materialization(
21335 &address,
21336 ProviderRuntimePolicy::raw_pty(),
21337 Some(environment_profile),
21338 Some(bundle),
21339 None,
21340 None,
21341 );
21342 selection.retain();
21343 instance_overlay.retain();
21344
21345 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21346 assert!(matches!(
21347 runtime.remove_native_launch_profile(&native_id),
21348 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21349 ));
21350 assert!(shared.remove_binding(&address).is_some());
21351 assert!(!shared
21352 .native_launch_profile_control
21353 .as_ref()
21354 .unwrap()
21355 .clear_native_instance_launch_overlay(address.session.instance_id));
21356 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21357 }
21358
21359 #[test]
21360 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21361 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21362 let address = SessionAddress {
21363 workspace_id: WorkspaceId::new("primary").unwrap(),
21364 session: SessionKey {
21365 instance_id: AgentInstanceId(201),
21366 generation: SessionGeneration(1),
21367 },
21368 };
21369 let error = match shared.prepare_session_materialization(
21370 &address,
21371 &agent("claude"),
21372 SessionMode::Pty,
21373 Some(&receipt),
21374 None,
21375 None,
21376 None,
21377 ) {
21378 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21379 Err(error) => error,
21380 };
21381 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21382 assert!(shared.handle.snapshot().sessions.is_empty());
21383 assert!(shared
21384 .session_bindings
21385 .lock()
21386 .unwrap_or_else(|poisoned| poisoned.into_inner())
21387 .is_empty());
21388 assert!(shared
21389 .materializations
21390 .lock()
21391 .unwrap_or_else(|poisoned| poisoned.into_inner())
21392 .is_empty());
21393 drop(shared);
21394 drop(runtime);
21395 std::fs::remove_dir_all(root).unwrap();
21396 }
21397
21398 #[test]
21399 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21400 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21401 let address = SessionAddress {
21402 workspace_id: WorkspaceId::new("primary").unwrap(),
21403 session: SessionKey {
21404 instance_id: AgentInstanceId(202),
21405 generation: SessionGeneration(1),
21406 },
21407 };
21408 let (overlay, materialization_guard) = shared
21409 .prepare_session_materialization(
21410 &address,
21411 &agent("claude"),
21412 SessionMode::Pty,
21413 Some(&receipt),
21414 None,
21415 None,
21416 None,
21417 )
21418 .unwrap()
21419 .unwrap();
21420 let materialization_id = materialization_guard.id().unwrap().clone();
21421 let materialization_root = shared
21422 .materializations
21423 .lock()
21424 .unwrap_or_else(|poisoned| poisoned.into_inner())
21425 .get(&materialization_id)
21426 .unwrap()
21427 .root()
21428 .to_path_buf();
21429 let selection = shared
21430 .select_environment_profile(
21431 address.session.instance_id,
21432 &agent("claude"),
21433 SessionMode::Pty,
21434 Some(&receipt),
21435 )
21436 .unwrap()
21437 .unwrap();
21438 assert!(shared
21439 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21440 .unwrap()
21441 .is_none());
21442 shared.bind_session_with_materialization(
21443 &address,
21444 ProviderRuntimePolicy::raw_pty(),
21445 Some(receipt),
21446 None,
21447 None,
21448 Some(materialization_id),
21449 );
21450 selection.retain();
21451 materialization_guard.retain();
21452
21453 assert!(materialization_root.is_dir());
21454 assert!(shared
21455 .session_bindings
21456 .lock()
21457 .unwrap_or_else(|poisoned| poisoned.into_inner())
21458 .contains_key(&address.session.instance_id));
21459
21460 let removed = shared.remove_binding(&address).unwrap();
21461 shared
21462 .cleanup_session_owned_materialization(&address, &removed)
21463 .unwrap();
21464 assert!(!materialization_root.exists());
21465 assert!(shared
21466 .materializations
21467 .lock()
21468 .unwrap_or_else(|poisoned| poisoned.into_inner())
21469 .is_empty());
21470 drop(shared);
21471 drop(runtime);
21472 std::fs::remove_dir_all(root).unwrap();
21473 }
21474
21475 #[tokio::test]
21476 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21477 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21478 let address = SessionAddress {
21479 workspace_id: WorkspaceId::new("primary").unwrap(),
21480 session: SessionKey {
21481 instance_id: AgentInstanceId(203),
21482 generation: SessionGeneration(1),
21483 },
21484 };
21485 let (overlay, materialization_guard) = shared
21486 .prepare_session_materialization(
21487 &address,
21488 &agent("claude"),
21489 SessionMode::Pty,
21490 Some(&receipt),
21491 None,
21492 None,
21493 None,
21494 )
21495 .unwrap()
21496 .unwrap();
21497 let materialization_id = materialization_guard.id().unwrap().clone();
21498 let materialization_root = shared
21499 .materializations
21500 .lock()
21501 .unwrap_or_else(|poisoned| poisoned.into_inner())
21502 .get(&materialization_id)
21503 .unwrap()
21504 .root()
21505 .to_path_buf();
21506 let selection = shared
21507 .select_environment_profile(
21508 address.session.instance_id,
21509 &agent("claude"),
21510 SessionMode::Pty,
21511 Some(&receipt),
21512 )
21513 .unwrap()
21514 .unwrap();
21515 assert!(shared
21516 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21517 .unwrap()
21518 .is_none());
21519 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21520 let record_id = shared
21521 .bind_spawn_session_with_materialization(
21522 &address,
21523 agent("claude"),
21524 SessionMode::Pty,
21525 policy,
21526 SpawnRecordPolicy::ProviderIdentityOnly,
21527 Some(receipt.clone()),
21528 None,
21529 None,
21530 Some(materialization_id.clone()),
21531 )
21532 .unwrap()
21533 .unwrap();
21534 selection.retain();
21535 materialization_guard.retain();
21536 assert_eq!(
21537 shared
21538 .materializations
21539 .lock()
21540 .unwrap_or_else(|poisoned| poisoned.into_inner())
21541 .get(&materialization_id)
21542 .unwrap()
21543 .owner(),
21544 &MaterializationOwner::Record {
21545 record_id: record_id.clone(),
21546 },
21547 );
21548
21549 let removed = shared.remove_binding(&address).unwrap();
21550 shared
21551 .cleanup_session_owned_materialization(&address, &removed)
21552 .unwrap();
21553 assert!(materialization_root.is_dir());
21554 assert!(shared
21555 .resolve_record_materialization(
21556 &record_id,
21557 &agent("claude"),
21558 SessionMode::Pty,
21559 Some(&receipt),
21560 None,
21561 None,
21562 )
21563 .unwrap()
21564 .is_some());
21565 {
21566 let mut records = shared
21567 .session_records
21568 .lock()
21569 .unwrap_or_else(|poisoned| poisoned.into_inner());
21570 let record = records.records.get_mut(&record_id).unwrap();
21571 record.active_session = None;
21572 record.state = ManagedSessionState::Unavailable;
21573 }
21574 shared.forget_session_record(&record_id).await.unwrap();
21575 assert!(!materialization_root.exists());
21576 assert!(shared
21577 .materializations
21578 .lock()
21579 .unwrap_or_else(|poisoned| poisoned.into_inner())
21580 .is_empty());
21581 drop(shared);
21582 drop(runtime);
21583 std::fs::remove_dir_all(root).unwrap();
21584 }
21585
21586 #[test]
21587 fn record_materialization_revalidation_failure_persists_recovery_required() {
21588 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21589 let state_path = root.join("node-state-v6.json");
21590 shared.state_path = Some(state_path.clone());
21591 let (_address, _record_id, materialization_id, materialization_root) =
21592 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21593 std::fs::write(
21594 materialization_root.join(".gate4agent-materialization-owner"),
21595 b"tampered",
21596 )
21597 .unwrap();
21598
21599 let error = match shared.resolve_record_materialization(
21600 &_record_id,
21601 &agent("claude"),
21602 SessionMode::Pty,
21603 Some(&receipt),
21604 None,
21605 None,
21606 ) {
21607 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21608 Err(error) => error,
21609 };
21610 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21611 assert_eq!(
21612 shared
21613 .materializations
21614 .lock()
21615 .unwrap_or_else(|poisoned| poisoned.into_inner())
21616 .get(&materialization_id)
21617 .unwrap()
21618 .state(),
21619 MaterializationState::RecoveryRequired,
21620 );
21621 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21622 assert_eq!(loaded.materializations.len(), 1);
21623 assert_eq!(
21624 loaded.materializations[0].state(),
21625 MaterializationState::RecoveryRequired,
21626 );
21627 drop(shared);
21628 drop(runtime);
21629 std::fs::remove_dir_all(root).unwrap();
21630 }
21631
21632 #[test]
21633 fn transient_record_materialization_resolution_failure_remains_ready() {
21634 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21635 let (_address, record_id, materialization_id, _materialization_root) =
21636 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21637 deny.store(true, Ordering::Release);
21638
21639 let error = match shared.resolve_record_materialization(
21640 &record_id,
21641 &agent("claude"),
21642 SessionMode::Pty,
21643 Some(&receipt),
21644 None,
21645 None,
21646 ) {
21647 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21648 Err(error) => error,
21649 };
21650 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21651 assert_eq!(
21652 shared
21653 .materializations
21654 .lock()
21655 .unwrap_or_else(|poisoned| poisoned.into_inner())
21656 .get(&materialization_id)
21657 .unwrap()
21658 .state(),
21659 MaterializationState::Ready,
21660 );
21661 drop(shared);
21662 drop(runtime);
21663 std::fs::remove_dir_all(root).unwrap();
21664 }
21665
21666 #[test]
21667 fn startup_missing_record_materialization_is_not_resumable() {
21668 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21669 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21670 shared
21671 .insert_record(ManagedSessionRecord {
21672 record_id: record_id.clone(),
21673 display_name: "missing materialization".to_owned(),
21674 provider: agent("claude"),
21675 mode: SessionMode::Pty,
21676 state: ManagedSessionState::Dormant,
21677 workspace_id: WorkspaceId::new("primary").unwrap(),
21678 canonical_root: opaque_windows_path(
21679 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21680 ),
21681 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21682 key: gate4agent_types::ProviderSessionKey::SessionId,
21683 id: "provider-session-missing-materialization".to_owned(),
21684 transcript_path: None,
21685 }),
21686 active_session: None,
21687 environment_profile: Some(receipt),
21688 bundle: None,
21689 context_id: None,
21690 context: None,
21691 exported_context: None,
21692 task_binding: None,
21693 created_at_unix_ms: 1,
21694 updated_at_unix_ms: 1,
21695 last_error: None,
21696 })
21697 .unwrap();
21698
21699 shared.reconcile_materializations();
21700 let record = shared.record(&record_id).unwrap();
21701 assert_eq!(record.state, ManagedSessionState::Unavailable);
21702 assert!(record.provider_session.is_none());
21703 assert_eq!(
21704 record.last_error.as_deref(),
21705 Some("environment-profile-unavailable"),
21706 );
21707 drop(shared);
21708 drop(runtime);
21709 std::fs::remove_dir_all(root).unwrap();
21710 }
21711
21712 #[tokio::test]
21713 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21714 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21715 let lease = install_managed_lease(&shared);
21716 let address = SessionAddress {
21717 workspace_id: WorkspaceId::new("primary").unwrap(),
21718 session: SessionKey {
21719 instance_id: AgentInstanceId(204),
21720 generation: SessionGeneration(1),
21721 },
21722 };
21723 let (overlay, materialization_guard) = shared
21724 .prepare_session_materialization(
21725 &address,
21726 &agent("claude"),
21727 SessionMode::Pty,
21728 Some(&receipt),
21729 None,
21730 None,
21731 Some(lease.lease_id.clone()),
21732 )
21733 .unwrap()
21734 .unwrap();
21735 let materialization_id = materialization_guard.id().unwrap().clone();
21736 let materialization_root = shared
21737 .materializations
21738 .lock()
21739 .unwrap_or_else(|poisoned| poisoned.into_inner())
21740 .get(&materialization_id)
21741 .unwrap()
21742 .root()
21743 .to_path_buf();
21744 let selection = shared
21745 .select_environment_profile(
21746 address.session.instance_id,
21747 &agent("claude"),
21748 SessionMode::Pty,
21749 Some(&receipt),
21750 )
21751 .unwrap()
21752 .unwrap();
21753 assert!(shared
21754 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21755 .unwrap()
21756 .is_none());
21757 shared.bind_session_with_materialization(
21758 &address,
21759 ProviderRuntimePolicy::raw_pty(),
21760 Some(receipt),
21761 None,
21762 None,
21763 Some(materialization_id),
21764 );
21765 selection.retain();
21766 materialization_guard.retain();
21767
21768 let blocked = shared
21769 .cleanup_managed_worktree(&lease.lease_id, false)
21770 .await
21771 .unwrap_err();
21772 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21773 assert!(materialization_root.is_dir());
21774 let removed = shared.remove_binding(&address).unwrap();
21775 shared
21776 .cleanup_session_owned_materialization(&address, &removed)
21777 .unwrap();
21778 assert!(!materialization_root.exists());
21779 drop(shared);
21780 drop(runtime);
21781 std::fs::remove_dir_all(root).unwrap();
21782 }
21783
21784 #[tokio::test]
21785 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21786 let git_root = temporary_workspace_root("raw-managed-inventory");
21787 let source = git_root.join("source");
21788 let allocation = git_root.join("allocation");
21789 std::fs::create_dir_all(&source).unwrap();
21790 std::fs::create_dir_all(&allocation).unwrap();
21791 run_test_git(&source, &["init"]);
21792 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21793 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21794 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21795 run_test_git(&source, &["add", "seed.txt"]);
21796 run_test_git(&source, &["commit", "-m", "seed"]);
21797 let profile = ManagedWorktreeProfile::new(
21798 WorktreeProfileId::new("default").unwrap(),
21799 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21800 &allocation,
21801 "gate4agent",
21802 "HEAD",
21803 ManagedWorktreeRetention::RemoveWhenReleased,
21804 )
21805 .unwrap();
21806 let workspace = WorkspaceConfig::new(
21807 WorkspaceId::new("primary").unwrap(),
21808 &source,
21809 )
21810 .unwrap()
21811 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21812 .with_managed_worktree_profile(profile.clone())
21813 .unwrap();
21814 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21815 materialization_test_shared_with_workspace(false, workspace);
21816 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21817 let source_text = source.to_string_lossy().into_owned();
21818 let target_text = target.to_string_lossy().into_owned();
21819 let created = create_git_worktree(
21820 &source_text,
21821 &target_text,
21822 "gate4agent/mw-raw-inventory",
21823 Some("HEAD"),
21824 )
21825 .await
21826 .unwrap();
21827 let lease = ManagedWorktreeLeaseRecord {
21828 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21829 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21830 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21831 profile_id: profile.profile_id().clone(),
21832 profile_revision: profile.revision().clone(),
21833 target_root: created.path.clone(),
21834 branch: "gate4agent/mw-raw-inventory".to_owned(),
21835 base_commit: created.head.clone(),
21836 expected_head: Some(created.head.clone()),
21837 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21838 state: ManagedWorktreeLeaseState::Ready,
21839 session_holders: Vec::new(),
21840 record_holders: Vec::new(),
21841 cleanup_failure: None,
21842 created_at_unix_ms: 1,
21843 updated_at_unix_ms: 1,
21844 };
21845 shared
21846 .workspaces
21847 .write()
21848 .unwrap_or_else(|poisoned| poisoned.into_inner())
21849 .insert(lease.workspace_id.clone(), created.path.clone());
21850 *shared
21851 .managed_worktrees
21852 .lock()
21853 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21854 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21855 let address = SessionAddress {
21856 workspace_id: lease.workspace_id.clone(),
21857 session: SessionKey {
21858 instance_id: AgentInstanceId(205),
21859 generation: SessionGeneration(1),
21860 },
21861 };
21862 let (overlay, materialization_guard) = shared
21863 .prepare_session_materialization(
21864 &address,
21865 &agent("claude"),
21866 SessionMode::Pty,
21867 Some(&receipt),
21868 None,
21869 None,
21870 Some(lease.lease_id.clone()),
21871 )
21872 .unwrap()
21873 .unwrap();
21874 let materialization_id = materialization_guard.id().unwrap().clone();
21875 let materialization_root = shared
21876 .materializations
21877 .lock()
21878 .unwrap_or_else(|poisoned| poisoned.into_inner())
21879 .get(&materialization_id)
21880 .unwrap()
21881 .root()
21882 .to_path_buf();
21883 let selection = shared
21884 .select_environment_profile(
21885 address.session.instance_id,
21886 &agent("claude"),
21887 SessionMode::Pty,
21888 Some(&receipt),
21889 )
21890 .unwrap()
21891 .unwrap();
21892 assert!(shared
21893 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21894 .unwrap()
21895 .is_none());
21896 let record_id = shared
21897 .bind_spawn_session_with_materialization(
21898 &address,
21899 agent("claude"),
21900 SessionMode::Pty,
21901 ProviderRuntimePolicy::raw_pty(),
21902 SpawnRecordPolicy::Always,
21903 Some(receipt.clone()),
21904 None,
21905 None,
21906 Some(materialization_id.clone()),
21907 )
21908 .unwrap()
21909 .unwrap();
21910 selection.retain();
21911 materialization_guard.retain();
21912 let record = shared.record(&record_id).unwrap();
21913 assert_eq!(record.state, ManagedSessionState::Live);
21914 assert!(record.provider_session.is_none());
21915 assert!(record.environment_profile.is_none());
21916 assert!(record.bundle.is_none());
21917 assert!(record.context_id.is_none());
21918 assert!(record.context.is_none());
21919 assert_eq!(record.active_session.as_ref(), Some(&address));
21920 assert_eq!(
21921 shared
21922 .materializations
21923 .lock()
21924 .unwrap_or_else(|poisoned| poisoned.into_inner())
21925 .get(&materialization_id)
21926 .unwrap()
21927 .owner(),
21928 &MaterializationOwner::Session {
21929 incarnation_id: shared.incarnation_id,
21930 instance_id: address.session.instance_id,
21931 generation: address.session.generation,
21932 },
21933 );
21934 {
21935 let mut bindings = shared
21936 .session_bindings
21937 .lock()
21938 .unwrap_or_else(|poisoned| poisoned.into_inner());
21939 bindings
21940 .get_mut(&address.session.instance_id)
21941 .unwrap()
21942 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21943 }
21944 let lease_snapshot = shared
21945 .managed_worktrees
21946 .lock()
21947 .unwrap_or_else(|poisoned| poisoned.into_inner())
21948 .bind_session(
21949 &lease.lease_id,
21950 ManagedWorktreeSessionHolder {
21951 incarnation_id: shared.incarnation_id,
21952 instance_id: address.session.instance_id,
21953 generation: address.session.generation,
21954 },
21955 None,
21956 unix_time_ms(),
21957 )
21958 .unwrap();
21959 assert_eq!(lease_snapshot.active_session_count, 1);
21960 assert_eq!(lease_snapshot.managed_record_count, 0);
21961 shared.persist_state().unwrap();
21962 assert!(shared
21963 .managed_worktrees
21964 .lock()
21965 .unwrap_or_else(|poisoned| poisoned.into_inner())
21966 .get(&lease.lease_id)
21967 .unwrap()
21968 .record_holders
21969 .is_empty());
21970 shared
21971 .handle
21972 .dispatch(shared.prepare_command(ControlCommand::Register {
21973 instance_id: address.session.instance_id,
21974 agent_id: agent("claude"),
21975 transport: TransportKind::Pty,
21976 }))
21977 .unwrap();
21978 runtime.tick().await;
21979 shared.reconcile_managed_record(
21980 &record_id,
21981 &address,
21982 &ControlEvent {
21983 sequence: 1,
21984 command_id: None,
21985 instance_id: address.session.instance_id,
21986 generation: address.session.generation,
21987 event: ControlEventKind::Removed,
21988 },
21989 false,
21990 false,
21991 );
21992 let remove = shared.remove_session(&address);
21993 let drive_runtime = async {
21994 runtime.tick().await;
21995 };
21996 let (removed, ()) = tokio::join!(remove, drive_runtime);
21997 removed.unwrap();
21998
21999 assert!(!materialization_root.exists());
22000 assert!(!Path::new(&created.path).exists());
22001 assert_eq!(
22002 shared
22003 .managed_worktrees
22004 .lock()
22005 .unwrap_or_else(|poisoned| poisoned.into_inner())
22006 .get(&lease.lease_id)
22007 .unwrap()
22008 .state,
22009 ManagedWorktreeLeaseState::Removed,
22010 );
22011 let renamed = shared
22012 .rename_session_record(&record_id, "raw managed inventory".to_owned())
22013 .unwrap();
22014 assert_eq!(renamed.record_id, record_id);
22015 assert_eq!(renamed.display_name, "raw managed inventory");
22016 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22017 assert!(renamed.active_session.is_none());
22018
22019 drop(shared);
22020 drop(runtime);
22021 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22022 std::fs::remove_dir_all(git_root).unwrap();
22023 }
22024
22025 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22026 let root = std::env::current_dir().unwrap()
22027 .join("managed-test-target")
22028 .to_string_lossy()
22029 .into_owned();
22030 let lease = ManagedWorktreeLeaseRecord {
22031 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22032 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22033 workspace_id: WorkspaceId::new("managed-test").unwrap(),
22034 profile_id: WorktreeProfileId::new("default").unwrap(),
22035 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22036 target_root: root.clone(),
22037 branch: "gate4agent/mw-test".to_owned(),
22038 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22039 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22040 retention: ManagedWorktreeRetention::RemoveWhenReleased,
22041 state: ManagedWorktreeLeaseState::Ready,
22042 session_holders: Vec::new(),
22043 record_holders: Vec::new(),
22044 cleanup_failure: None,
22045 created_at_unix_ms: 1,
22046 updated_at_unix_ms: 1,
22047 };
22048 shared.workspaces.write()
22049 .unwrap_or_else(|poisoned| poisoned.into_inner())
22050 .insert(lease.workspace_id.clone(), root);
22051 *shared.managed_worktrees.lock()
22052 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22053 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22054 lease
22055 }
22056
22057 #[cfg(feature = "fixture")]
22058 #[test]
22059 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22060 let mut shared = terminal_test_shared();
22061 shared.fixture_semantic_hook_policy = true;
22062 let lease = install_managed_lease(&shared);
22063 let address = SessionAddress {
22064 workspace_id: lease.workspace_id.clone(),
22065 session: SessionKey {
22066 instance_id: AgentInstanceId(206),
22067 generation: SessionGeneration(1),
22068 },
22069 };
22070 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22071 let effective_runtime_policy =
22072 shared.effective_spawn_runtime_policy(pre_admission_policy);
22073 assert_ne!(effective_runtime_policy, pre_admission_policy);
22074 assert!(effective_runtime_policy.provider_session_identity);
22075
22076 let record_id = shared
22077 .bind_spawn_session(
22078 &address,
22079 agent("claude"),
22080 SessionMode::Pty,
22081 effective_runtime_policy,
22082 SpawnRecordPolicy::Always,
22083 None,
22084 )
22085 .unwrap()
22086 .unwrap();
22087 assert_eq!(
22088 shared.record(&record_id).unwrap().state,
22089 ManagedSessionState::IdentityPending,
22090 );
22091 let (record_holder, raw_inventory_record_id) = {
22092 let mut bindings = shared
22093 .session_bindings
22094 .lock()
22095 .unwrap_or_else(|poisoned| poisoned.into_inner());
22096 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22097 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22098 managed_spawn_record_ownership(binding, effective_runtime_policy)
22099 };
22100 assert_eq!(record_holder.as_ref(), Some(&record_id));
22101 assert!(raw_inventory_record_id.is_none());
22102 let snapshot = shared
22103 .managed_worktrees
22104 .lock()
22105 .unwrap_or_else(|poisoned| poisoned.into_inner())
22106 .bind_session(
22107 &lease.lease_id,
22108 ManagedWorktreeSessionHolder {
22109 incarnation_id: shared.incarnation_id,
22110 instance_id: address.session.instance_id,
22111 generation: address.session.generation,
22112 },
22113 record_holder,
22114 unix_time_ms(),
22115 )
22116 .unwrap();
22117 assert_eq!(snapshot.managed_record_count, 1);
22118 assert_eq!(
22119 shared
22120 .managed_worktrees
22121 .lock()
22122 .unwrap_or_else(|poisoned| poisoned.into_inner())
22123 .get(&lease.lease_id)
22124 .unwrap()
22125 .record_holders,
22126 vec![record_id.clone()],
22127 );
22128
22129 let identity = ProviderSessionIdentity {
22130 key: ProviderSessionKey::SessionId,
22131 id: "monitoring-fixture-managed-session".to_owned(),
22132 transcript_path: None,
22133 };
22134 shared.publish_control(ControlEvent {
22135 sequence: 1,
22136 command_id: None,
22137 instance_id: address.session.instance_id,
22138 generation: address.session.generation,
22139 event: ControlEventKind::ProviderEvent {
22140 sequence: 1,
22141 source: gate4agent_types::ProviderSource {
22142 family: AdapterFamily::Hook,
22143 binding: AdapterBinding::new(
22144 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22145 "fixture/v1",
22146 gate4agent_types::AdapterVerification::SyntheticFixture,
22147 )
22148 .unwrap(),
22149 },
22150 source_sequence: 1,
22151 event: ProviderEvent::SessionIdentityObserved {
22152 identity: identity.clone(),
22153 },
22154 },
22155 });
22156
22157 let live = shared.record(&record_id).unwrap();
22158 assert_eq!(live.state, ManagedSessionState::Live);
22159 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22160 assert_eq!(live.active_session.as_ref(), Some(&address));
22161 assert_eq!(
22162 shared
22163 .managed_worktrees
22164 .lock()
22165 .unwrap_or_else(|poisoned| poisoned.into_inner())
22166 .get(&lease.lease_id)
22167 .unwrap()
22168 .record_holders,
22169 vec![record_id],
22170 );
22171 }
22172
22173 fn managed_v2_test_shared(
22174 allocation_root: &Path,
22175 state_path: Option<PathBuf>,
22176 profile_revision: &str,
22177 ) -> NodeShared {
22178 let profile = ManagedWorktreeProfile::new(
22179 WorktreeProfileId::new("default").unwrap(),
22180 WorktreeProfileRevision::new(profile_revision).unwrap(),
22181 allocation_root,
22182 "gate4agent",
22183 "HEAD",
22184 ManagedWorktreeRetention::RemoveWhenReleased,
22185 )
22186 .unwrap();
22187 let workspace = WorkspaceConfig::new(
22188 WorkspaceId::new("primary").unwrap(),
22189 std::env::current_dir().unwrap(),
22190 )
22191 .unwrap()
22192 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22193 .with_managed_worktree_profile(profile)
22194 .unwrap();
22195 let catalog = active_registry().unwrap();
22196 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22197 let mut shared = NodeShared::new(
22198 handle,
22199 "fixture-token".to_owned(),
22200 NodeId::new("node-terminal-test").unwrap(),
22201 vec![workspace],
22202 vec![agent("claude")],
22203 );
22204 shared.state_path = state_path;
22205 shared
22206 }
22207
22208 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22209 let mut spawn_spec = spawn_spec_fixture();
22210 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22211 ManagedWorktreeSpawnRequestV2 {
22212 spawn_spec,
22213 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22214 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22215 }
22216 }
22217
22218 #[test]
22219 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22220 let shared = terminal_test_shared();
22221 let mut lease = install_managed_lease(&shared);
22222 let session = SessionAddress {
22223 workspace_id: lease.workspace_id.clone(),
22224 session: SessionKey {
22225 instance_id: AgentInstanceId(73),
22226 generation: SessionGeneration(4),
22227 },
22228 };
22229 lease.state = ManagedWorktreeLeaseState::InUse;
22230 lease.session_holders.push(ManagedWorktreeSessionHolder {
22231 incarnation_id: shared.incarnation_id,
22232 instance_id: session.session.instance_id,
22233 generation: session.session.generation,
22234 });
22235 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22236 assert!(resolved.target.worktree_id.is_none());
22237
22238 let receipt = managed_spawn_receipt(
22239 &resolved,
22240 shared.incarnation_id,
22241 session.clone(),
22242 lease.snapshot(),
22243 None,
22244 None,
22245 None,
22246 );
22247 assert!(resolved.target.worktree_id.is_none());
22248 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22249 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22250 assert_eq!(receipt.spawn.session, session);
22251 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22252 let encoded = serde_json::to_vec(&receipt).unwrap();
22253 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22254 assert_eq!(decoded, receipt);
22255 }
22256
22257 #[tokio::test]
22258 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22259 let root = temporary_workspace_root("managed-v2-revision");
22260 let allocation = root.join("allocation");
22261 std::fs::create_dir_all(&allocation).unwrap();
22262 let shared = managed_v2_test_shared(
22263 &allocation,
22264 Some(root.join("node-state.json")),
22265 "v1",
22266 );
22267 let mut request = managed_v2_request("managed-v2-revision");
22268 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22269
22270 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22271
22272 assert_eq!(
22273 failure.code,
22274 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22275 );
22276 assert!(shared
22277 .managed_worktrees
22278 .lock()
22279 .unwrap_or_else(|poisoned| poisoned.into_inner())
22280 .records()
22281 .is_empty());
22282 assert!(shared
22283 .managed_spawn_replays
22284 .lock()
22285 .unwrap_or_else(|poisoned| poisoned.into_inner())
22286 .is_empty());
22287 std::fs::remove_dir_all(root).unwrap();
22288 }
22289
22290 #[test]
22291 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22292 let root = temporary_workspace_root("managed-v2-concurrent");
22293 let allocation = root.join("allocation");
22294 std::fs::create_dir_all(&allocation).unwrap();
22295 let shared = Arc::new(managed_v2_test_shared(
22296 &allocation,
22297 Some(root.join("node-state.json")),
22298 "v1",
22299 ));
22300 let request = managed_v2_request("managed-v2-concurrent");
22301 let barrier = Arc::new(std::sync::Barrier::new(3));
22302 let mut joins = Vec::new();
22303 for _ in 0..2 {
22304 let shared = Arc::clone(&shared);
22305 let request = request.clone();
22306 let barrier = Arc::clone(&barrier);
22307 joins.push(std::thread::spawn(move || {
22308 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22309 barrier.wait();
22310 shared.acquire_durable_managed_spawn_v2(&request, digest)
22311 }));
22312 }
22313 barrier.wait();
22314 let results = joins
22315 .into_iter()
22316 .map(|join| join.join().unwrap())
22317 .collect::<Vec<_>>();
22318 assert_eq!(
22319 results
22320 .iter()
22321 .filter(|result| {
22322 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22323 })
22324 .count(),
22325 1,
22326 );
22327 assert_eq!(
22328 results
22329 .iter()
22330 .filter(|result| {
22331 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22332 })
22333 .count(),
22334 1,
22335 );
22336 assert_eq!(
22337 shared
22338 .managed_spawn_replays
22339 .lock()
22340 .unwrap_or_else(|poisoned| poisoned.into_inner())
22341 .len(),
22342 1,
22343 );
22344 assert!(shared
22345 .managed_worktrees
22346 .lock()
22347 .unwrap_or_else(|poisoned| poisoned.into_inner())
22348 .records()
22349 .is_empty());
22350 drop(shared);
22351 std::fs::remove_dir_all(root).unwrap();
22352 }
22353
22354 #[tokio::test]
22355 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22356 let root = temporary_workspace_root("managed-v2-linked-pending");
22357 let allocation = root.join("allocation");
22358 std::fs::create_dir_all(&allocation).unwrap();
22359 let state_path = root.join("node-state.json");
22360 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22361 let lease = install_managed_lease(&shared);
22362 let request = managed_v2_request("managed-v2-linked-pending");
22363 assert!(matches!(
22364 shared
22365 .acquire_durable_managed_spawn_v2(
22366 &request,
22367 managed_spawn_request_digest_v2(&request).unwrap(),
22368 )
22369 .unwrap(),
22370 DurableManagedSpawnReplayDecision::Reserved,
22371 ));
22372 {
22373 let _transaction = shared
22374 .state_transaction
22375 .lock()
22376 .unwrap_or_else(|poisoned| poisoned.into_inner());
22377 shared
22378 .link_durable_managed_spawn_v2_lease_locked(
22379 &request.spawn_spec.idempotency_key,
22380 &lease.lease_id,
22381 )
22382 .unwrap();
22383 shared.persist_state_locked().unwrap();
22384 }
22385 drop(shared);
22386
22387 let mut loaded = session_registry::load(
22388 Some(&state_path),
22389 &NodeId::new("node-terminal-test").unwrap(),
22390 )
22391 .unwrap();
22392 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22393 *reopened
22394 .managed_worktrees
22395 .lock()
22396 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22397 ManagedWorktreeRegistry::from_records(
22398 std::mem::take(&mut loaded.managed_worktrees),
22399 std::mem::take(&mut loaded.managed_worktree_tombstones),
22400 )
22401 .unwrap();
22402 *reopened
22403 .managed_spawn_replays
22404 .lock()
22405 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22406 .managed_spawn_replays
22407 .into_iter()
22408 .map(|record| (record.idempotency_key.clone(), record))
22409 .collect();
22410 let lease_count = reopened
22411 .managed_worktrees
22412 .lock()
22413 .unwrap_or_else(|poisoned| poisoned.into_inner())
22414 .records()
22415 .len();
22416
22417 let failure = reopened
22418 .spawn_managed_worktree_v2(request)
22419 .await
22420 .unwrap_err();
22421
22422 assert_eq!(
22423 failure.code,
22424 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22425 );
22426 assert_eq!(
22427 reopened
22428 .managed_worktrees
22429 .lock()
22430 .unwrap_or_else(|poisoned| poisoned.into_inner())
22431 .records()
22432 .len(),
22433 lease_count,
22434 );
22435 std::fs::remove_dir_all(root).unwrap();
22436 }
22437
22438 #[tokio::test]
22439 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22440 let root = temporary_workspace_root("managed-v2-reopen");
22441 let allocation = root.join("allocation");
22442 std::fs::create_dir_all(&allocation).unwrap();
22443 let state_path = root.join("node-state.json");
22444 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22445 let lease = install_managed_lease(&shared);
22446 let request = managed_v2_request("managed-v2-reopen");
22447 let session = SessionAddress {
22448 workspace_id: lease.workspace_id.clone(),
22449 session: SessionKey {
22450 instance_id: AgentInstanceId(91),
22451 generation: SessionGeneration(1),
22452 },
22453 };
22454 let snapshot = {
22455 let mut registry = shared
22456 .managed_worktrees
22457 .lock()
22458 .unwrap_or_else(|poisoned| poisoned.into_inner());
22459 let current = registry.get_mut(&lease.lease_id).unwrap();
22460 current.state = ManagedWorktreeLeaseState::InUse;
22461 current.session_holders.push(ManagedWorktreeSessionHolder {
22462 incarnation_id: shared.incarnation_id,
22463 instance_id: session.session.instance_id,
22464 generation: session.session.generation,
22465 });
22466 current.snapshot()
22467 };
22468 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22469 let receipt = managed_spawn_receipt(
22470 &resolved,
22471 shared.incarnation_id,
22472 session,
22473 snapshot,
22474 None,
22475 None,
22476 None,
22477 );
22478 assert!(matches!(
22479 shared
22480 .acquire_durable_managed_spawn_v2(
22481 &request,
22482 managed_spawn_request_digest_v2(&request).unwrap(),
22483 )
22484 .unwrap(),
22485 DurableManagedSpawnReplayDecision::Reserved,
22486 ));
22487 {
22488 let _transaction = shared
22489 .state_transaction
22490 .lock()
22491 .unwrap_or_else(|poisoned| poisoned.into_inner());
22492 shared
22493 .link_durable_managed_spawn_v2_lease_locked(
22494 &request.spawn_spec.idempotency_key,
22495 &lease.lease_id,
22496 )
22497 .unwrap();
22498 shared.persist_state_locked().unwrap();
22499 }
22500 shared
22501 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22502 .unwrap();
22503 let legacy_same_key = ManagedWorktreeSpawnRequest {
22504 spawn_spec: request.spawn_spec.clone(),
22505 worktree_profile_id: request.worktree_profile_id.clone(),
22506 };
22507 shared.remember_managed_spawn_attempt(
22508 Some(&request.spawn_spec.idempotency_key),
22509 legacy_same_key.clone(),
22510 Ok(receipt.clone()),
22511 Instant::now(),
22512 );
22513 assert!(shared
22514 .replay_managed_spawn(&legacy_same_key, Instant::now())
22515 .unwrap()
22516 .is_none());
22517 drop(shared);
22518
22519 let mut loaded = session_registry::load(
22520 Some(&state_path),
22521 &NodeId::new("node-terminal-test").unwrap(),
22522 )
22523 .unwrap();
22524 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22525 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22526 *reopened
22527 .managed_worktrees
22528 .lock()
22529 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22530 ManagedWorktreeRegistry::from_records(
22531 std::mem::take(&mut loaded.managed_worktrees),
22532 std::mem::take(&mut loaded.managed_worktree_tombstones),
22533 )
22534 .unwrap();
22535 *reopened
22536 .managed_spawn_replays
22537 .lock()
22538 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22539 .managed_spawn_replays
22540 .into_iter()
22541 .map(|record| (record.idempotency_key.clone(), record))
22542 .collect();
22543 let lease_count_before = reopened
22544 .managed_worktrees
22545 .lock()
22546 .unwrap_or_else(|poisoned| poisoned.into_inner())
22547 .records()
22548 .len();
22549
22550 assert_eq!(
22551 reopened
22552 .spawn_managed_worktree_v2(request.clone())
22553 .await
22554 .unwrap(),
22555 receipt,
22556 );
22557 assert_eq!(
22558 reopened
22559 .managed_worktrees
22560 .lock()
22561 .unwrap_or_else(|poisoned| poisoned.into_inner())
22562 .records()
22563 .len(),
22564 lease_count_before,
22565 );
22566 let mut changed = request;
22567 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22568 let failure = reopened
22569 .spawn_managed_worktree_v2(changed)
22570 .await
22571 .unwrap_err();
22572 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22573 assert_eq!(
22574 reopened
22575 .managed_worktrees
22576 .lock()
22577 .unwrap_or_else(|poisoned| poisoned.into_inner())
22578 .records()
22579 .len(),
22580 lease_count_before,
22581 );
22582 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22583 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22584 let failure = reopened
22585 .spawn_managed_worktree_v2(new_key)
22586 .await
22587 .unwrap_err();
22588 assert_eq!(
22589 failure.code,
22590 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22591 );
22592 assert_eq!(
22593 reopened
22594 .managed_worktrees
22595 .lock()
22596 .unwrap_or_else(|poisoned| poisoned.into_inner())
22597 .records()
22598 .len(),
22599 lease_count_before,
22600 );
22601 assert_eq!(
22602 reopened
22603 .managed_spawn_replays
22604 .lock()
22605 .unwrap_or_else(|poisoned| poisoned.into_inner())
22606 .len(),
22607 1,
22608 );
22609 std::fs::remove_dir_all(root).unwrap();
22610 }
22611
22612 #[test]
22613 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22614 let shared = terminal_test_shared();
22615 let request = ManagedWorktreeSpawnRequest {
22616 spawn_spec: spawn_spec_fixture(),
22617 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22618 };
22619 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22620 shared.remember_managed_spawn(
22621 request.clone(),
22622 Err(expected.clone()),
22623 Instant::now(),
22624 );
22625
22626 assert_eq!(
22627 shared
22628 .replay_managed_spawn(&request, Instant::now())
22629 .unwrap()
22630 .unwrap(),
22631 Err(expected),
22632 );
22633 assert!(shared
22634 .managed_spawn_replays
22635 .lock()
22636 .unwrap_or_else(|poisoned| poisoned.into_inner())
22637 .is_empty());
22638 }
22639
22640 #[tokio::test]
22641 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22642 let shared = terminal_test_shared();
22643 let lease = install_managed_lease(&shared);
22644 let raw = shared.spawn_session(
22645 lease.workspace_id.clone(),
22646 agent("claude"),
22647 SessionMode::Pty,
22648 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22649 None,
22650 ).await.unwrap_err();
22651 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22652 let mut spec = spawn_spec_fixture();
22653 spec.target.workspace_id = lease.workspace_id.clone();
22654 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22655 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22656 let mut selected = spawn_spec_fixture();
22657 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22658 selected.target.worktree_id = Some(lease.workspace_id);
22659 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22660 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22661 assert!(shared.handle.snapshot().sessions.is_empty());
22662 }
22663
22664 #[test]
22665 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22666 let shared = terminal_test_shared();
22667 let lease = install_managed_lease(&shared);
22668 let snapshot = snapshot_for_wire(
22669 &shared, true, true, true, false, true, true, false, false, true,
22670 );
22671 assert!(snapshot.managed_worktrees.is_empty());
22672 assert!(snapshot.workspaces.iter().all(|workspace| {
22673 workspace.managed_worktree_profiles.is_none()
22674 }));
22675 let mut reply = ResponseEnvelope {
22676 request_id: 1,
22677 result: Ok(NodeResponse::Resync {
22678 event_sequence: 1,
22679 oldest_available_sequence: 1,
22680 snapshot: shared.snapshot(),
22681 events: vec![NodeEventEnvelope {
22682 sequence: 1,
22683 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22684 }],
22685 }),
22686 };
22687 project_response_without_managed_worktrees(&mut reply);
22688 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22689 panic!("projection changed the response kind");
22690 };
22691 assert!(snapshot.managed_worktrees.is_empty());
22692 assert!(events.is_empty());
22693 let mut git = git_snapshot_for_parser();
22694 git.branch = Some(lease.branch.clone());
22695 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22696 lease_id: lease.lease_id.clone(),
22697 source_workspace_id: lease.source_workspace_id.clone(),
22698 branch: lease.branch.clone(),
22699 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22700 active_session_count: 1,
22701 managed_record_count: 0,
22702 });
22703 let mut inspection_reply = ResponseEnvelope {
22704 request_id: 2,
22705 result: Ok(NodeResponse::WorkspaceInspected {
22706 inspection: WorkspaceInspection {
22707 workspace_id: lease.workspace_id.clone(),
22708 entries: Vec::new(),
22709 tree_truncated: false,
22710 git,
22711 truncation: None,
22712 },
22713 }),
22714 };
22715 project_response_without_managed_worktrees(&mut inspection_reply);
22716 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22717 panic!("projection changed the inspection response kind");
22718 };
22719 assert!(inspection.git.managed_worktree.is_none());
22720 let failure = managed_git_worktree_failure(GitWorktreeError {
22721 kind: GitWorktreeErrorKind::Failed,
22722 message: r"git failed at C:\private\managed-target".to_owned(),
22723 });
22724 assert!(!failure.message.contains("private"));
22725 assert!(!failure.message.contains("managed-target"));
22726 }
22727
22728 #[tokio::test]
22729 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22730 let root = temporary_workspace_root("managed-restart");
22731 let source = root.join("source");
22732 let allocation = root.join("allocation");
22733 std::fs::create_dir_all(&source).unwrap();
22734 std::fs::create_dir_all(&allocation).unwrap();
22735 run_test_git(&source, &["init"]);
22736 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22737 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22738 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22739 run_test_git(&source, &["add", "seed.txt"]);
22740 run_test_git(&source, &["commit", "-m", "seed"]);
22741 let profile = ManagedWorktreeProfile::new(
22742 WorktreeProfileId::new("default").unwrap(),
22743 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22744 &allocation,
22745 "gate4agent",
22746 "HEAD",
22747 ManagedWorktreeRetention::Retain,
22748 ).unwrap();
22749 let workspace = WorkspaceConfig::new(
22750 WorkspaceId::new("primary").unwrap(),
22751 &source,
22752 ).unwrap()
22753 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22754 .with_managed_worktree_profile(profile.clone()).unwrap();
22755 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22756 let source_text = workspace.canonical_root().to_owned();
22757 let target_text = target.to_string_lossy().into_owned();
22758 let created = create_git_worktree(
22759 &source_text,
22760 &target_text,
22761 "gate4agent/mw-restart",
22762 Some("HEAD"),
22763 ).await.unwrap();
22764 let lease = ManagedWorktreeLeaseRecord {
22765 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22766 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22767 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22768 profile_id: profile.profile_id().clone(),
22769 profile_revision: profile.revision().clone(),
22770 target_root: created.path.clone(),
22771 branch: "gate4agent/mw-restart".to_owned(),
22772 base_commit: created.head.clone(),
22773 expected_head: None,
22774 retention: ManagedWorktreeRetention::Retain,
22775 state: ManagedWorktreeLeaseState::Allocating,
22776 session_holders: Vec::new(),
22777 record_holders: Vec::new(),
22778 cleanup_failure: None,
22779 created_at_unix_ms: 1,
22780 updated_at_unix_ms: 1,
22781 };
22782 let catalog = active_registry().unwrap();
22783 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22784 let shared = NodeShared::new_with_incarnation(
22785 handle,
22786 "fixture-token".to_owned(),
22787 NodeId::new("node-managed-restart").unwrap(),
22788 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22789 vec![workspace],
22790 vec![agent("claude")],
22791 ProviderRuntimeStatuses::default(),
22792 None,
22793 Vec::new(),
22794 Vec::new(),
22795 SpawnProfileRegistry::default(),
22796 None,
22797 None,
22798 None,
22799 None,
22800 Vec::new(),
22801 vec![lease.clone()],
22802 Vec::new(),
22803 Vec::new(),
22804 None,
22805 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22806 );
22807 shared.reconcile_managed_worktrees().await;
22808 let retained = shared.managed_worktrees.lock()
22809 .unwrap_or_else(|poisoned| poisoned.into_inner())
22810 .get(&lease.lease_id).unwrap().clone();
22811 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22812 assert!(shared.workspaces.read()
22813 .unwrap_or_else(|poisoned| poisoned.into_inner())
22814 .contains_key(&lease.workspace_id));
22815 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22816 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22817 assert!(!Path::new(&created.path).exists());
22818 std::fs::remove_dir_all(root).unwrap();
22819 }
22820
22821 fn run_test_git(root: &Path, arguments: &[&str]) {
22822 let output = std::process::Command::new("git")
22823 .arg("-C").arg(root).args(arguments).output().unwrap();
22824 assert!(
22825 output.status.success(),
22826 "git {:?} failed: {}",
22827 arguments,
22828 String::from_utf8_lossy(&output.stderr),
22829 );
22830 }
22831
22832 #[tokio::test]
22833 async fn spawn_spec_validation_fails_before_session_mutation() {
22834 let shared = terminal_test_shared();
22835 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22836 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22837 }));
22838 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22839 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22840 }));
22841 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22842 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22843 }));
22844
22845 let mut target_mismatch = spawn_spec_fixture();
22846 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22847 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22848 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22849
22850 let mut unknown_profile = spawn_spec_fixture();
22851 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22852 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22853 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22854 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22855
22856 let mut revision_mismatch = spawn_spec_fixture();
22857 revision_mismatch.idempotency_key =
22858 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22859 revision_mismatch.expected_profile_revision =
22860 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22861 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22862 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22863
22864 let mut unsupported_materializer = spawn_spec_fixture();
22865 unsupported_materializer.idempotency_key =
22866 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22867 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22868 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22869 };
22870 let failure = shared
22871 .spawn_from_spec(unsupported_materializer)
22872 .await
22873 .unwrap_err();
22874 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22875
22876 let failure = shared
22877 .spawn_session_with_deadline(
22878 WorkspaceId::new("primary").unwrap(),
22879 agent("claude"),
22880 SessionMode::Pty,
22881 gate4agent_types::TerminalSize {
22882 rows: 24,
22883 columns: 80,
22884 },
22885 None,
22886 None,
22887 None,
22888 None,
22889 None,
22890 None,
22891 SpawnRecordPolicy::ProviderIdentityOnly,
22892 Some(Instant::now()),
22893 &[],
22894 None,
22895 ApprovalLevel::default(),
22896 None,
22897 None,
22898 )
22899 .await
22900 .unwrap_err();
22901 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22902 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22903 assert!(shared.handle.snapshot().sessions.is_empty());
22904 }
22905
22906 #[test]
22907 fn worktree_selection_requires_its_separate_negotiated_capability() {
22908 let mut spec = spawn_spec_fixture();
22909 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22910 let request = NodeRequest::SpawnSpec { spec };
22911 let spawn_spec_capability =
22912 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22913 assert!(request_uses_unnegotiated_capability(
22914 &request,
22915 &[spawn_spec_capability.clone()],
22916 ));
22917 let worktree_capability =
22918 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22919 assert!(request_uses_unnegotiated_capability(
22920 &request,
22921 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22922 ));
22923 let profile_revision_capability =
22924 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22925 assert!(request_uses_unnegotiated_capability(
22926 &request,
22927 &[
22928 spawn_spec_capability.clone(),
22929 worktree_capability.clone(),
22930 profile_revision_capability.clone(),
22931 ],
22932 ));
22933 let bundle_materialization_capability =
22940 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22941 assert!(request_uses_unnegotiated_capability(
22942 &request,
22943 &[
22944 spawn_spec_capability.clone(),
22945 worktree_capability.clone(),
22946 profile_revision_capability.clone(),
22947 bundle_materialization_capability.clone(),
22948 ],
22949 ));
22950 let history_context_pack_capability =
22951 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22952 assert!(!request_uses_unnegotiated_capability(
22953 &request,
22954 &[
22955 spawn_spec_capability,
22956 worktree_capability,
22957 profile_revision_capability,
22958 bundle_materialization_capability,
22959 history_context_pack_capability,
22960 ],
22961 ));
22962 }
22963
22964 #[tokio::test]
22965 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22966 let mut shared = terminal_test_shared();
22967 let spec = spawn_spec_fixture();
22968 let receipt = shared
22969 .resolve_spawn_spec(&spec)
22970 .unwrap()
22971 .receipt(shared.incarnation_id, terminal_address(1));
22972 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22973
22974 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22975 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22976 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22977
22978 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22979
22980 let mut revision_conflicting = spec.clone();
22981 revision_conflicting.expected_profile_revision =
22982 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22983 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22984 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22985
22986 let mut stale_cache_miss = spec.clone();
22987 stale_cache_miss.idempotency_key =
22988 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22989 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22990 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22991
22992 let mut conflicting = spec;
22993 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22994 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22995 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22996 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22997 assert!(shared.handle.snapshot().sessions.is_empty());
22998 }
22999
23000 #[tokio::test]
23001 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23002 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23003 let mut spec = spawn_spec_fixture();
23004 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23005 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23006 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23007
23008 let failure = shared
23009 .create_worktree(
23010 WorkspaceId::new("primary").unwrap(),
23011 WorkspaceId::new("new-worktree").unwrap(),
23012 "not-an-absolute-path".to_owned(),
23013 "invalid branch".to_owned(),
23014 None,
23015 )
23016 .await
23017 .unwrap_err();
23018 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23019
23020 let failure = shared
23021 .remove_worktree(
23022 WorkspaceId::new("primary").unwrap(),
23023 "not-an-absolute-path".to_owned(),
23024 )
23025 .await
23026 .unwrap_err();
23027 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23028 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23029 assert!(shared.handle.snapshot().sessions.is_empty());
23030 }
23031
23032 #[tokio::test]
23033 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23034 let root = temporary_workspace_root("manual-worktree-compensation");
23035 let source = root.join("source");
23036 let target = root.join("target");
23037 let invalid_state_parent = root.join("state-parent-is-a-file");
23038 let invalid_state_path = invalid_state_parent.join("state.json");
23039 std::fs::create_dir_all(&source).unwrap();
23040 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23041 run_test_git(&source, &["init"]);
23042 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23043 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23044 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23045 run_test_git(&source, &["add", "seed.txt"]);
23046 run_test_git(&source, &["commit", "-m", "seed"]);
23047
23048 let catalog = active_registry().unwrap();
23049 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23050 let workspace = WorkspaceConfig::new(
23051 WorkspaceId::new("primary").unwrap(),
23052 &source,
23053 ).unwrap();
23054 let mut shared = NodeShared::new(
23055 handle,
23056 "fixture-token".to_owned(),
23057 NodeId::new("node-worktree-compensation").unwrap(),
23058 vec![workspace],
23059 vec![agent("claude")],
23060 );
23061 shared.state_path = Some(invalid_state_path);
23062
23063 let error = shared.create_worktree(
23064 WorkspaceId::new("primary").unwrap(),
23065 WorkspaceId::new("compensated").unwrap(),
23066 target.to_string_lossy().into_owned(),
23067 "gate4agent/compensated".to_owned(),
23068 Some("HEAD".to_owned()),
23069 ).await.unwrap_err();
23070
23071 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23072 assert!(!target.exists());
23073 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23074 .iter()
23075 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23076 std::fs::remove_dir_all(root).unwrap();
23077 }
23078
23079 #[test]
23080 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23081 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23082 NativeGitWorktreeSnapshot {
23083 path: path.to_owned(),
23084 head: "0123456789abcdef".to_owned(),
23085 branch: Some("refs/heads/test".to_owned()),
23086 is_bare,
23087 is_main,
23088 locked: false,
23089 lock_reason: None,
23090 prunable: false,
23091 prunable_reason: None,
23092 workspace_id: None,
23093 }
23094 }
23095
23096 let source = r"C:\repo";
23097 let selected = r"C:\repo-worktree";
23098 let listed = vec![
23099 worktree(source, true, false),
23100 worktree(selected, false, false),
23101 ];
23102 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23103
23104 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23105 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23106
23107 let bare = vec![
23108 worktree(source, true, false),
23109 worktree(selected, false, true),
23110 ];
23111 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23112 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23113
23114 let foreign_failure =
23115 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23116 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23117 }
23118
23119 fn terminal_frame(sequence: u64) -> TerminalFrame {
23120 TerminalFrame {
23121 sequence,
23122 size: gate4agent_types::TerminalSize {
23123 rows: 24,
23124 columns: 80,
23125 },
23126 cursor_row: 2,
23127 cursor_column: 3,
23128 contents: format!("frame-{sequence}"),
23129 formatted: vec![1, 2, 3],
23130 scrollback_formatted: Vec::new(),
23131 alternate_screen: false,
23132 mouse_protocol_enabled: false,
23133 mouse_protocol_encoding: Default::default(),
23134 produced_at_unix_ms: 0,
23135 screen_state: gate4agent_types::PtyScreenState::default(),
23136 bracketed_paste: None,
23137 }
23138 }
23139
23140 #[test]
23141 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23142 let shared = terminal_test_shared();
23143 let mut receiver = shared.terminal_event_tx.subscribe();
23144 let original = terminal_address(1);
23145
23146 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23147 let first = receiver.try_recv().unwrap();
23148 assert_eq!(first.len(), 1);
23149 assert!(matches!(
23150 &first[0].event,
23151 NodeEvent::TerminalFrame { address, frame }
23152 if address == &original && frame.sequence == 7
23153 ));
23154
23155 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23156 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23157 assert!(matches!(
23158 receiver.try_recv(),
23159 Err(broadcast::error::TryRecvError::Empty)
23160 ));
23161
23162 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23163 let advanced = receiver.try_recv().unwrap();
23164 assert_eq!(advanced[0].sequence, 2);
23165
23166 let resumed = terminal_address(2);
23167 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23168 let reset = receiver.try_recv().unwrap();
23169 assert_eq!(reset[0].sequence, 3);
23170 assert!(matches!(
23171 &reset[0].event,
23172 NodeEvent::TerminalFrame { address, frame }
23173 if address == &resumed && frame.sequence == 1
23174 ));
23175 shared.clear_terminal_frame_watermark(&resumed);
23176 assert!(!shared
23177 .terminal_frame_watermarks
23178 .lock()
23179 .unwrap_or_else(|poisoned| poisoned.into_inner())
23180 .contains_key(&resumed.session.instance_id));
23181 }
23182
23183 #[test]
23184 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23185 let shared = terminal_test_shared();
23186 let mut receiver = shared.terminal_event_tx.subscribe();
23187 let address = terminal_address(1);
23188 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23189
23190 for sequence in 1..=count {
23191 shared.publish_terminal_frame_candidates(vec![(
23192 address.clone(),
23193 terminal_frame(sequence),
23194 )]);
23195 }
23196
23197 let history = shared
23198 .history
23199 .lock()
23200 .unwrap_or_else(|poisoned| poisoned.into_inner());
23201 assert!(history.events.is_empty());
23202 assert_eq!(history.last_sequence, count);
23203 assert_eq!(history.replay_floor_sequence, 1);
23204 drop(history);
23205 let NodeResponse::Resync {
23206 event_sequence,
23207 oldest_available_sequence,
23208 events,
23209 ..
23210 } = shared.resync(0) else {
23211 unreachable!("resync helper returned another response")
23212 };
23213 assert_eq!(event_sequence, count);
23214 assert_eq!(oldest_available_sequence, 1);
23215 assert!(events.is_empty());
23216 assert!(matches!(
23217 resync_required_event(&shared).event,
23218 NodeEvent::ResyncRequired {
23219 oldest_available_sequence: 1,
23220 }
23221 ));
23222 assert!(matches!(
23223 receiver.try_recv(),
23224 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23225 ));
23226 let latest = receiver.try_recv().unwrap();
23227 assert_eq!(latest.len(), 1);
23228 assert_eq!(latest[0].sequence, count);
23229 }
23230
23231 #[test]
23232 fn real_durable_eviction_advances_authoritative_replay_floor() {
23233 let shared = terminal_test_shared();
23234 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23235 shared.publish(NodeEvent::ControllerChanged { controller: None });
23236 }
23237
23238 let NodeResponse::Resync {
23239 event_sequence,
23240 oldest_available_sequence,
23241 events,
23242 ..
23243 } = shared.resync(0) else {
23244 unreachable!("resync helper returned another response")
23245 };
23246 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23247 assert_eq!(oldest_available_sequence, 2);
23248 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23249 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23250 assert!(matches!(
23251 resync_required_event(&shared).event,
23252 NodeEvent::ResyncRequired {
23253 oldest_available_sequence: 2,
23254 }
23255 ));
23256 }
23257
23258 #[test]
23259 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23260 let shared = terminal_test_shared();
23261 let without_capability = terminal_event_subscription(&shared, false);
23262 let with_capability = terminal_event_subscription(&shared, true);
23263 assert!(without_capability.is_none());
23264 assert!(with_capability.is_some());
23265 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23266 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23267 }));
23268 }
23269
23270 #[test]
23271 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23272 let mut pending = Vec::new();
23273 let mut event_sequence = 0_u64;
23274 for frame_sequence in 1..=4 {
23275 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23276 event_sequence += 1;
23277 let mut address = terminal_address(1);
23278 address.session.instance_id = AgentInstanceId(instance);
23279 queue_connection_event(
23280 &mut pending,
23281 NodeEventEnvelope {
23282 sequence: event_sequence,
23283 event: NodeEvent::TerminalFrame {
23284 address,
23285 frame: terminal_frame(frame_sequence),
23286 },
23287 },
23288 0,
23289 );
23290 }
23291 }
23292 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23293 pending.sort_unstable_by_key(|event| event.sequence);
23294
23295 let mut uninterrupted = pending.clone();
23296 let first_burst = connection_event_burst_len(uninterrupted.len());
23297 uninterrupted.drain(..first_burst);
23298 let second_burst = connection_event_burst_len(uninterrupted.len());
23299 uninterrupted.drain(..second_burst);
23300 assert!(uninterrupted.is_empty());
23301
23302 let (frame_tx, mut frame_rx) =
23303 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23304 frame_tx
23305 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23306 request_id: 77,
23307 request: NodeRequest::Snapshot,
23308 })))
23309 .unwrap();
23310 let burst_len = connection_event_burst_len(pending.len());
23311 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23312 pending.drain(..burst_len);
23313 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23314 assert!(matches!(
23315 frame_rx.try_recv(),
23316 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23317 request_id: 77,
23318 request: NodeRequest::Snapshot,
23319 })))
23320 ));
23321 }
23322
23323 #[test]
23324 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23325 let mut pending = Vec::new();
23326 for sequence in [7_u64, 8] {
23327 let mut address = terminal_address(1);
23328 address.session.instance_id = AgentInstanceId(sequence);
23329 queue_connection_event(
23330 &mut pending,
23331 NodeEventEnvelope {
23332 sequence,
23333 event: NodeEvent::TerminalFrame {
23334 address,
23335 frame: terminal_frame(sequence),
23336 },
23337 },
23338 0,
23339 );
23340 }
23341 let mut discard_through = 0;
23342 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23343 assert!(pending.is_empty());
23344
23345 let replayed = NodeEventEnvelope {
23346 sequence: 8,
23347 event: NodeEvent::TerminalFrame {
23348 address: terminal_address(1),
23349 frame: terminal_frame(8),
23350 },
23351 };
23352 let advanced = NodeEventEnvelope {
23353 sequence: 9,
23354 event: NodeEvent::TerminalFrame {
23355 address: terminal_address(2),
23356 frame: terminal_frame(1),
23357 },
23358 };
23359 queue_connection_event_batch(
23360 &mut pending,
23361 &[replayed, advanced.clone()],
23362 discard_through,
23363 );
23364 assert_eq!(pending, vec![advanced]);
23365 }
23366
23367 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23368 ManagedSessionRecord {
23369 record_id: SessionRecordId::new(record_id).unwrap(),
23370 display_name: format!("{provider} record"),
23371 provider: agent(provider),
23372 mode: SessionMode::Pty,
23373 state: ManagedSessionState::Unavailable,
23374 workspace_id: WorkspaceId::new("primary").unwrap(),
23375 canonical_root: opaque_windows_path(
23376 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23377 ),
23378 provider_session: None,
23379 active_session: None,
23380 environment_profile: None,
23381 bundle: None,
23382 context_id: None,
23383 context: None,
23384 exported_context: None,
23385 task_binding: None,
23386 created_at_unix_ms: 1,
23387 updated_at_unix_ms: 1,
23388 last_error: None,
23389 }
23390 }
23391
23392 fn retention_test_record(
23393 workspace_id: &str,
23394 record_id: &str,
23395 state: ManagedSessionState,
23396 updated_at_unix_ms: u64,
23397 ) -> ManagedSessionRecord {
23398 ManagedSessionRecord {
23399 record_id: SessionRecordId::new(record_id).unwrap(),
23400 display_name: format!("{record_id} display"),
23401 provider: agent("claude"),
23402 mode: SessionMode::Pty,
23403 state,
23404 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23405 canonical_root: opaque_windows_path(
23406 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23407 ),
23408 provider_session: None,
23409 active_session: None,
23410 environment_profile: None,
23411 bundle: None,
23412 context_id: None,
23413 context: None,
23414 exported_context: None,
23415 task_binding: None,
23416 created_at_unix_ms: updated_at_unix_ms,
23417 updated_at_unix_ms,
23418 last_error: None,
23419 }
23420 }
23421
23422 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23430 vec![
23431 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23432 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23433 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23434 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23435 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23436 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23437 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23438 retention_test_record(
23439 "workspace-a",
23440 "decoy-pending",
23441 ManagedSessionState::IdentityPending,
23442 now - 500_000,
23443 ),
23444 retention_test_record(
23445 "workspace-a",
23446 "decoy-dormant",
23447 ManagedSessionState::Dormant,
23448 now - 500_000,
23449 ),
23450 ]
23451 }
23452
23453 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23454 let root = temporary_workspace_root("session-record-retention");
23455 std::fs::create_dir_all(&root).unwrap();
23456 let catalog = active_registry().unwrap();
23457 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23458 let workspace_a =
23459 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23460 let workspace_b =
23461 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23462 let mut shared = NodeShared::new(
23463 handle,
23464 "fixture-token".to_owned(),
23465 NodeId::new("node-retention-test").unwrap(),
23466 vec![workspace_a, workspace_b],
23467 vec![agent("claude")],
23468 );
23469 shared.state_path = Some(root.join("node-state-v10.json"));
23470 let now = unix_time_ms();
23471 for record in retention_fixture_records(now) {
23472 shared.insert_record(record).unwrap();
23473 }
23474 shared
23475 .persist_state_locked()
23476 .expect("fixture-only, empty-history persist against a fresh temp state path");
23477 (shared, runtime, root, now)
23478 }
23479
23480 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23481 shared
23482 .history
23483 .lock()
23484 .unwrap_or_else(|poisoned| poisoned.into_inner())
23485 .events
23486 .iter()
23487 .filter_map(|envelope| match &envelope.event {
23488 NodeEvent::SessionRecordRemoved { record_id } => {
23489 Some(record_id.as_str().to_owned())
23490 }
23491 _ => None,
23492 })
23493 .collect()
23494 }
23495
23496 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23497 shared
23498 .session_records
23499 .lock()
23500 .unwrap_or_else(|poisoned| poisoned.into_inner())
23501 .records
23502 .keys()
23503 .map(|record_id| record_id.as_str().to_owned())
23504 .collect()
23505 }
23506
23507 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23508 values.iter().map(|value| (*value).to_owned()).collect()
23509 }
23510
23511 #[tokio::test]
23512 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23513 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23514 shared.session_record_retention = SessionRecordRetentionConfig {
23515 age_ms: 0,
23516 keep_per_workspace: 1,
23517 };
23518 shared.retire_unavailable_session_records().await;
23519
23520 assert_eq!(
23521 retention_remaining_record_ids(&shared),
23522 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23523 );
23524 assert_eq!(
23525 retention_removed_record_ids(&shared),
23526 owned(&["a1", "a2", "b1", "b2"]),
23527 );
23528 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23529
23530 drop(shared);
23531 drop(runtime);
23532 std::fs::remove_dir_all(root).unwrap();
23533 }
23534
23535 #[tokio::test]
23541 async fn session_record_retention_both_set_retires_only_the_intersection() {
23542 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23543 shared.session_record_retention = SessionRecordRetentionConfig {
23544 age_ms: 250_000,
23545 keep_per_workspace: 1,
23546 };
23547 shared.retire_unavailable_session_records().await;
23548
23549 assert_eq!(
23550 retention_remaining_record_ids(&shared),
23551 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23552 );
23553 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23554 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23555
23556 drop(shared);
23557 drop(runtime);
23558 std::fs::remove_dir_all(root).unwrap();
23559 }
23560
23561 #[tokio::test]
23562 async fn session_record_retention_both_disabled_is_a_no_op() {
23563 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23564 shared.session_record_retention = SessionRecordRetentionConfig::default();
23565 shared.retire_unavailable_session_records().await;
23566
23567 assert_eq!(
23568 retention_remaining_record_ids(&shared),
23569 owned(&[
23570 "a1", "a2", "a3", "b1", "b2", "b3",
23571 "decoy-live", "decoy-pending", "decoy-dormant",
23572 ]),
23573 );
23574 assert!(retention_removed_record_ids(&shared).is_empty());
23575 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23576
23577 drop(shared);
23578 drop(runtime);
23579 std::fs::remove_dir_all(root).unwrap();
23580 }
23581
23582 #[test]
23583 fn session_record_context_export_selects_one_exact_history_identity() {
23584 let candidates = vec![
23585 HistoryCandidateSummary {
23586 id: "candidate-other".to_owned(),
23587 session_id_hint: "provider-other".to_owned(),
23588 modified_at_unix_ms: Some(1),
23589 },
23590 HistoryCandidateSummary {
23591 id: "candidate-exact".to_owned(),
23592 session_id_hint: "provider-exact".to_owned(),
23593 modified_at_unix_ms: Some(2),
23594 },
23595 ];
23596 assert_eq!(
23597 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23598 "candidate-exact",
23599 );
23600 }
23601
23602 #[test]
23603 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23604 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23605 id: id.to_owned(),
23606 session_id_hint: hint.to_owned(),
23607 modified_at_unix_ms: None,
23608 };
23609 let missing = select_history_candidate(
23610 &[candidate("candidate-other", "provider-other")],
23611 Some("provider-exact"),
23612 )
23613 .unwrap_err();
23614 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23615 let ambiguous = select_history_candidate(
23616 &[
23617 candidate("candidate-a", "provider-exact"),
23618 candidate("candidate-b", "provider-exact"),
23619 ],
23620 Some("provider-exact"),
23621 )
23622 .unwrap_err();
23623 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23624 }
23625
23626 #[test]
23634 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23635 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23636 id: id.to_owned(),
23637 session_id_hint: hint.to_owned(),
23638 modified_at_unix_ms: None,
23639 };
23640 assert_eq!(
23641 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23642 .unwrap(),
23643 "candidate-sole",
23644 );
23645 let missing = select_history_candidate(&[], None).unwrap_err();
23646 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23647 let ambiguous = select_history_candidate(
23648 &[
23649 candidate("candidate-a", "sidecar-session-a"),
23650 candidate("candidate-b", "sidecar-session-b"),
23651 ],
23652 None,
23653 )
23654 .unwrap_err();
23655 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23656 }
23657
23658 #[test]
23659 fn session_record_context_export_revalidation_rejects_record_change() {
23660 let session = terminal_address(1);
23661 let identity = ProviderSessionIdentity {
23662 key: ProviderSessionKey::SessionId,
23663 id: "provider-exact".to_owned(),
23664 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23665 };
23666 let mut expected = record("claude", "record-context-export");
23667 expected.state = ManagedSessionState::Live;
23668 expected.provider_session = Some(identity.clone());
23669 expected.active_session = Some(session.clone());
23670 let binding = SessionBinding {
23671 workspace_id: session.workspace_id.clone(),
23672 generation: session.session.generation,
23673 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23674 pending_resume: None,
23675 record_id: Some(expected.record_id.clone()),
23676 managed_worktree_lease_id: None,
23677 environment_profile: None,
23678 bundle: None,
23679 context: None,
23680 materialization_id: None,
23681 };
23682 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23683 assert!(session_record_context_export_binding_is_exact(
23684 &expected,
23685 &expected,
23686 Some(&identity),
23687 &session,
23688 &binding,
23689 &expected.provider,
23690 ¤t_root,
23691 false,
23692 ));
23693 let mut changed = expected.clone();
23694 changed.updated_at_unix_ms += 1;
23695 assert!(!session_record_context_export_binding_is_exact(
23696 &expected,
23697 &changed,
23698 Some(&identity),
23699 &session,
23700 &binding,
23701 &expected.provider,
23702 ¤t_root,
23703 false,
23704 ));
23705 assert!(!session_record_context_export_binding_is_exact(
23710 &expected,
23711 &changed,
23712 Some(&identity),
23713 &session,
23714 &binding,
23715 &expected.provider,
23716 ¤t_root,
23717 true,
23718 ));
23719 let mut detached = expected.clone();
23720 detached.state = ManagedSessionState::Dormant;
23721 detached.active_session = None;
23722 detached.updated_at_unix_ms += 1;
23723 assert!(!session_record_context_export_binding_is_exact(
23724 &expected,
23725 &detached,
23726 Some(&identity),
23727 &session,
23728 &binding,
23729 &expected.provider,
23730 ¤t_root,
23731 false,
23732 ));
23733 assert!(session_record_context_export_binding_is_exact(
23734 &expected,
23735 &detached,
23736 Some(&identity),
23737 &session,
23738 &binding,
23739 &expected.provider,
23740 ¤t_root,
23741 true,
23742 ));
23743 }
23744
23745 #[test]
23752 fn session_record_context_export_binding_is_exact_without_identity() {
23753 let session = terminal_address(1);
23754 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23755 expected.state = ManagedSessionState::Live;
23756 expected.active_session = Some(session.clone());
23757 let binding = SessionBinding {
23758 workspace_id: session.workspace_id.clone(),
23759 generation: session.session.generation,
23760 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23761 pending_resume: None,
23762 record_id: Some(expected.record_id.clone()),
23763 managed_worktree_lease_id: None,
23764 environment_profile: None,
23765 bundle: None,
23766 context: None,
23767 materialization_id: None,
23768 };
23769 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23770 assert!(session_record_context_export_binding_is_exact(
23771 &expected,
23772 &expected,
23773 None,
23774 &session,
23775 &binding,
23776 &expected.provider,
23777 ¤t_root,
23778 false,
23779 ));
23780 let mut drifted = expected.clone();
23784 drifted.provider_session = Some(ProviderSessionIdentity {
23785 key: ProviderSessionKey::SessionId,
23786 id: "unexpected-identity".to_owned(),
23787 transcript_path: None,
23788 });
23789 assert!(!session_record_context_export_binding_is_exact(
23790 &expected,
23791 &drifted,
23792 None,
23793 &session,
23794 &binding,
23795 &expected.provider,
23796 ¤t_root,
23797 false,
23798 ));
23799 }
23800
23801 #[test]
23802 fn session_record_context_export_preflight_requires_live_usable_source() {
23803 let mut source = record("claude", "record-context-export-preflight");
23804 source.state = ManagedSessionState::Live;
23805 assert!(session_record_context_export_source_is_usable(
23806 &source,
23807 &SessionStatus::Running,
23808 false,
23809 ));
23810 assert!(session_record_context_export_source_is_usable(
23811 &source,
23812 &SessionStatus::Exited { exit_code: Some(0) },
23813 false,
23814 ));
23815
23816 source.state = ManagedSessionState::Dormant;
23817 assert!(!session_record_context_export_source_is_usable(
23818 &source,
23819 &SessionStatus::Running,
23820 false,
23821 ));
23822 assert!(session_record_context_export_source_is_usable(
23826 &source,
23827 &SessionStatus::Exited { exit_code: Some(0) },
23828 true,
23829 ));
23830 assert!(!session_record_context_export_source_is_usable(
23831 &source,
23832 &SessionStatus::Exited { exit_code: Some(0) },
23833 false,
23834 ));
23835 source.state = ManagedSessionState::Live;
23836 assert!(!session_record_context_export_source_is_usable(
23837 &source,
23838 &SessionStatus::Starting,
23839 false,
23840 ));
23841 }
23842
23843 #[test]
23844 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23845 let catalog = active_registry().unwrap();
23846 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23847 let workspace = WorkspaceConfig::new(
23848 WorkspaceId::new("primary").unwrap(),
23849 std::env::current_dir().unwrap(),
23850 )
23851 .unwrap();
23852 let shared = NodeShared::new(
23853 handle,
23854 "fixture-token".to_owned(),
23855 NodeId::new("node-context-export-conflict").unwrap(),
23856 vec![workspace],
23857 vec![agent("claude")],
23858 );
23859 let history = HistorySessionRecord {
23860 session_id: "provider-exact".to_owned(),
23861 title: None,
23862 cwd: None,
23863 model: None,
23864 message_count: 1,
23865 completed_turn_count: None,
23866 total_tokens: 0,
23867 messages: vec![gate4agent_types::HistoryMessageRecord {
23868 role: gate4agent_types::HistoryMessageRole::User,
23869 text: "bounded context".to_owned(),
23870 }],
23871 };
23872 let pack = NodeContextPack::export(
23873 ContextPackLineageReceipt {
23874 source_node_id: shared.node_id.clone(),
23875 source_session: terminal_address(1),
23876 source_provider: agent("claude"),
23877 },
23878 &history,
23879 )
23880 .unwrap();
23881 let context_id = pack.receipt().id.clone();
23882 let session = terminal_address(1);
23883 let identity = ProviderSessionIdentity {
23884 key: ProviderSessionKey::SessionId,
23885 id: history.session_id.clone(),
23886 transcript_path: None,
23887 };
23888 let mut expected = record("claude", "record-context-export-final-conflict");
23889 expected.state = ManagedSessionState::Live;
23890 expected.provider_session = Some(identity.clone());
23891 expected.active_session = Some(session.clone());
23892 let mut changed = expected.clone();
23893 changed.updated_at_unix_ms += 1;
23894 shared.insert_record(changed).unwrap();
23895
23896 let error = shared
23897 .commit_context_pack_for_session_record(
23898 &expected,
23899 Some(&identity),
23900 &session,
23901 "candidate-exact",
23902 &history,
23903 pack,
23904 false,
23905 )
23906 .unwrap_err();
23907
23908 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23909 assert!(shared
23910 .context_catalog
23911 .read()
23912 .unwrap_or_else(|poisoned| poisoned.into_inner())
23913 .get(&context_id)
23914 .is_none());
23915 }
23916
23917 #[tokio::test]
23918 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23919 let root = temporary_workspace_root("session-task-binding");
23920 std::fs::create_dir_all(&root).unwrap();
23921 let state_path = root.join("node-state.json");
23922 let catalog = active_registry().unwrap();
23923 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23924 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23925 let node_id = NodeId::new("node-1").unwrap();
23926 let mut shared = NodeShared::new(
23927 handle,
23928 "fixture-token".to_owned(),
23929 node_id.clone(),
23930 vec![workspace],
23931 vec![agent("claude")],
23932 );
23933 shared.state_path = Some(state_path.clone());
23934 let record = record("claude", "sr-task");
23935 let record_id = record.record_id.clone();
23936 shared.insert_record(record).unwrap();
23937 shared.persist_state().unwrap();
23938 shared
23939 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23940 .unwrap();
23941
23942 let minted = process_request_inner(
23943 &shared,
23944 77,
23945 ClientRole::Operator,
23946 NodeRequest::SetSessionTask {
23947 record_id: record_id.clone(),
23948 expected_revision: 0,
23949 target: SessionTaskTargetV1::New,
23950 },
23951 )
23952 .await
23953 .unwrap();
23954 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23955 panic!("session task mutation returned another response");
23956 };
23957 let minted_binding = minted.task_binding.clone().unwrap();
23958 let minted_task_id = minted_binding.task_id.clone().unwrap();
23959 assert_eq!(minted_binding.revision, 1);
23960 assert!(minted_task_id.as_str().starts_with("task-"));
23961 assert_eq!(minted_task_id.as_str().len(), 29);
23962
23963 let event_count = shared.current_sequence();
23964 let repeated = process_request_inner(
23965 &shared,
23966 77,
23967 ClientRole::Operator,
23968 NodeRequest::SetSessionTask {
23969 record_id: record_id.clone(),
23970 expected_revision: 1,
23971 target: SessionTaskTargetV1::Existing {
23972 task_id: minted_task_id.clone(),
23973 },
23974 },
23975 )
23976 .await
23977 .unwrap();
23978 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23979 assert_eq!(shared.current_sequence(), event_count);
23980
23981 let stale = process_request_inner(
23982 &shared,
23983 77,
23984 ClientRole::Operator,
23985 NodeRequest::SetSessionTask {
23986 record_id: record_id.clone(),
23987 expected_revision: 0,
23988 target: SessionTaskTargetV1::Existing {
23989 task_id: minted_task_id,
23990 },
23991 },
23992 )
23993 .await
23994 .unwrap_err();
23995 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23996
23997 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23998 let assigned = process_request_inner(
23999 &shared,
24000 77,
24001 ClientRole::Operator,
24002 NodeRequest::SetSessionTask {
24003 record_id: record_id.clone(),
24004 expected_revision: 1,
24005 target: SessionTaskTargetV1::Existing {
24006 task_id: assigned_id.clone(),
24007 },
24008 },
24009 )
24010 .await
24011 .unwrap();
24012 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24013 panic!("session task assignment returned another response");
24014 };
24015 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24016 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24017
24018 let cleared = process_request_inner(
24019 &shared,
24020 77,
24021 ClientRole::Operator,
24022 NodeRequest::SetSessionTask {
24023 record_id: record_id.clone(),
24024 expected_revision: 2,
24025 target: SessionTaskTargetV1::Clear,
24026 },
24027 )
24028 .await
24029 .unwrap();
24030 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24031 panic!("session task clear returned another response");
24032 };
24033 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24034 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24035 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24036 assert_eq!(
24037 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24038 cleared.updated_at_unix_ms,
24039 );
24040
24041 let persisted = std::fs::read(&state_path).unwrap();
24042 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24043 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24044 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24045 std::fs::remove_dir_all(root).unwrap();
24046 }
24047
24048 #[test]
24049 fn session_task_capability_strips_snapshots_events_and_replies() {
24050 let mut bound = record("claude", "sr-task-projection");
24051 bound.updated_at_unix_ms = 2;
24052 bound.task_binding = Some(SessionTaskBindingV1 {
24053 revision: 1,
24054 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24055 changed_at_unix_ms: 2,
24056 });
24057 let request = NodeRequest::SetSessionTask {
24058 record_id: bound.record_id.clone(),
24059 expected_revision: 1,
24060 target: SessionTaskTargetV1::Clear,
24061 };
24062 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24063 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24064 }));
24065 assert!(!request_uses_unnegotiated_capability(
24066 &request,
24067 &baseline_capabilities().unwrap(),
24068 ));
24069 assert!(request_uses_unnegotiated_capability(&request, &[]));
24070
24071 let mut snapshot = terminal_test_shared().snapshot();
24072 snapshot.session_records.push(bound.clone());
24073 let mut reply = ResponseEnvelope {
24074 request_id: 1,
24075 result: Ok(NodeResponse::Resync {
24076 event_sequence: 1,
24077 oldest_available_sequence: 1,
24078 snapshot,
24079 events: vec![NodeEventEnvelope {
24080 sequence: 1,
24081 event: NodeEvent::SessionRecordUpserted {
24082 record: bound.clone(),
24083 },
24084 }],
24085 }),
24086 };
24087 project_response_without_session_task_binding(&mut reply);
24088 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24089 panic!("task projection changed the response kind");
24090 };
24091 assert!(snapshot.session_records[0].task_binding.is_none());
24092 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24093 panic!("task projection changed the event kind");
24094 };
24095 assert!(record.task_binding.is_none());
24096
24097 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24098 sequence: 2,
24099 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24100 });
24101 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24102 panic!("task event projection changed the event kind");
24103 };
24104 assert!(record.task_binding.is_none());
24105
24106 let mut direct = ResponseEnvelope {
24107 request_id: 2,
24108 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24109 };
24110 project_response_without_session_task_binding(&mut direct);
24111 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24112 panic!("task reply projection changed the response kind");
24113 };
24114 assert!(record.task_binding.is_none());
24115 }
24116
24117 #[test]
24118 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24119 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24120 let workspace = WorkspaceConfig::new(
24121 WorkspaceId::new("primary").unwrap(),
24122 std::env::current_dir().unwrap(),
24123 )
24124 .unwrap();
24125 let config = NodeServerConfig::new(
24126 DEFAULT_NODE_ENDPOINT,
24127 "fixture-token",
24128 NodeId::new("node-1").unwrap(),
24129 [workspace],
24130 )
24131 .unwrap();
24132 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24133 }
24134
24135 #[cfg(all(feature = "fixture", windows))]
24136 #[test]
24137 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24138 let root = temporary_workspace_root("provider-bundle-argv-hold");
24139 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24140 std::fs::create_dir_all(&root).unwrap();
24141 std::fs::create_dir_all(&outside).unwrap();
24142 let proof_path = root.join("provider.proof");
24143 let release_signal = root.join("release.signal");
24144 let config = NodeServerConfig::new(
24145 format!(
24146 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24147 std::process::id(),
24148 ),
24149 "fixture-token",
24150 NodeId::new("provider-bundle-hold-node").unwrap(),
24151 [WorkspaceConfig::new(
24152 WorkspaceId::new("primary").unwrap(),
24153 std::env::current_dir().unwrap(),
24154 )
24155 .unwrap()],
24156 )
24157 .unwrap()
24158 .with_spawn_profiles(SpawnProfileRegistry::new([
24159 crate::protocol::SpawnProfileDefaults {
24160 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24161 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24162 .unwrap(),
24163 provider: agent("kimi"),
24164 mode: SessionMode::Pty,
24165 terminal_size: gate4agent_types::TerminalSize {
24166 rows: 24,
24167 columns: 80,
24168 },
24169 prompt: None,
24170 bundle_id: None,
24171 context_id: None,
24172 environment_profile_id: None,
24173 },
24174 ])
24175 .unwrap());
24176 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24177 config,
24178 agent("kimi"),
24179 proof_path.clone(),
24180 release_signal.clone(),
24181 )
24182 .unwrap();
24183 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24184
24185 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24186 agent("kimi"),
24187 proof_path.clone(),
24188 Some(release_signal.clone()),
24189 )
24190 .unwrap();
24191 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24192 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24193 let release_wait = wrapper
24194 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24195 .unwrap();
24196 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24197 assert!(proof_write < release_wait);
24198 assert!(release_wait < fixture_ready);
24199 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24200 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24201 assert_eq!(
24202 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24203 &[
24204 proof_path.to_string_lossy().into_owned(),
24205 release_signal.to_string_lossy().into_owned(),
24206 ],
24207 );
24208
24209 let error = NodeServer::provider_bundle_argv_fixture_spec(
24210 agent("kimi"),
24211 proof_path,
24212 Some(outside.join("release.signal")),
24213 )
24214 .unwrap_err();
24215 assert!(matches!(
24216 error,
24217 NodeServerError::Registry(message)
24218 if message == "provider bundle argv release path must share the proof parent"
24219 ));
24220 drop(server);
24221 std::fs::remove_dir_all(root).unwrap();
24222 std::fs::remove_dir_all(outside).unwrap();
24223 }
24224
24225 #[cfg(feature = "fixture")]
24226 #[test]
24227 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24228 let root = temporary_workspace_root("context-fixture-registry");
24229 std::fs::create_dir_all(&root).unwrap();
24230 let proof_path = root.join("context.proof");
24231 let config = NodeServerConfig::new(
24232 format!(
24233 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24234 std::process::id(),
24235 ),
24236 "fixture-token",
24237 NodeId::new("context-fixture-node").unwrap(),
24238 [WorkspaceConfig::new(
24239 WorkspaceId::new("primary").unwrap(),
24240 std::env::current_dir().unwrap(),
24241 )
24242 .unwrap()],
24243 )
24244 .unwrap();
24245 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24246 let mut enabled = server
24247 .shared
24248 .enabled_providers
24249 .iter()
24250 .map(AgentId::as_str)
24251 .collect::<Vec<_>>();
24252 enabled.sort_unstable();
24253 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24254 for provider in &server.shared.enabled_providers {
24255 assert!(server
24256 .shared
24257 .provider_adapter_contracts
24258 .iter()
24259 .any(|contract| {
24260 &contract.provider == provider
24261 && contract.family == AdapterFamily::History
24262 }));
24263 }
24264 drop(server);
24265 std::fs::remove_dir_all(root).unwrap();
24266 }
24267
24268 #[cfg(feature = "fixture")]
24269 #[test]
24270 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24271 let root = temporary_workspace_root("monitoring-context-fixture");
24272 std::fs::create_dir_all(&root).unwrap();
24273 let proof_path = root.join("context.proof");
24274 let standard = NodeServer::context_pack_fixture_catalog(
24275 proof_path.clone(),
24276 None,
24277 false,
24278 ).unwrap();
24279 let monitoring = NodeServer::context_pack_fixture_catalog(
24280 proof_path.clone(),
24281 None,
24282 true,
24283 ).unwrap();
24284 let claude = AgentId::new("claude").unwrap();
24285 let standard_claude = standard.get(&claude).unwrap();
24286 let monitoring_claude = monitoring.get(&claude).unwrap();
24287 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24291 assert_eq!(
24292 monitoring_claude.capabilities.adapters.history,
24293 standard_claude.capabilities.adapters.history,
24294 );
24295 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24296 let codex = AgentId::new("codex").unwrap();
24297 let standard_codex = standard.get(&codex).unwrap();
24298 let monitoring_codex = monitoring.get(&codex).unwrap();
24299 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24300 assert_eq!(
24301 monitoring_codex.capabilities.adapters.history,
24302 standard_codex.capabilities.adapters.history,
24303 );
24304 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24305 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24306 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24307 .unwrap();
24308 for required in [
24309 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24310 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24311 "@('role', 'text')",
24312 "g4a-private-provider-session-canary",
24313 "private-provider-login-identity",
24314 "$rawProof",
24315 "$env:GATE4AGENT_HOOK_URL",
24316 "g4a-private-provider-session-canary",
24317 ] {
24318 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24319 }
24320 for provider in ["grok", "kimi"] {
24321 let provider = AgentId::new(provider).unwrap();
24322 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24323 }
24324
24325 let config = NodeServerConfig::new(
24326 format!(
24327 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24328 std::process::id(),
24329 ),
24330 "fixture-token",
24331 NodeId::new("monitoring-context-fixture-node").unwrap(),
24332 [WorkspaceConfig::new(
24333 WorkspaceId::new("primary").unwrap(),
24334 std::env::current_dir().unwrap(),
24335 )
24336 .unwrap()],
24337 )
24338 .unwrap();
24339 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24340 assert!(server.shared.fixture_semantic_hook_policy);
24341 drop(server);
24342 std::fs::remove_dir_all(root).unwrap();
24343 }
24344
24345 #[cfg(feature = "fixture")]
24346 #[test]
24347 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24348 let root = temporary_workspace_root("kimi-context-only-fixture");
24349 std::fs::create_dir_all(&root).unwrap();
24350 let proof_path = root.join("context.proof");
24351 let standard = NodeServer::context_pack_fixture_catalog(
24352 proof_path.clone(),
24353 None,
24354 false,
24355 ).unwrap();
24356 let kimi = AgentId::new("kimi").unwrap();
24357 let context_only = NodeServer::context_pack_fixture_catalog(
24358 proof_path,
24359 Some(&kimi),
24360 false,
24361 )
24362 .unwrap();
24363
24364 let codex = AgentId::new("codex").unwrap();
24365 assert_eq!(
24366 standard.get(&codex).unwrap().launch.fixed_args,
24367 context_only.get(&codex).unwrap().launch.fixed_args,
24368 );
24369 let script = context_only
24370 .get(&kimi)
24371 .unwrap()
24372 .launch
24373 .fixed_args
24374 .iter()
24375 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24376 .expect("Kimi context-only validation script is missing");
24377 for required in [
24378 "$bundleArgs.Count -ne 0",
24379 "GATE4AGENT_CONTEXT_ROOT",
24380 "context-pack.json",
24381 "$entries.Count -ne 1",
24382 "g4a-context-pack-v1",
24383 "$document.PSObject.Properties['cwd']",
24384 "'claude', 'codex', 'grok', 'kimi'",
24385 "$hasUser",
24386 "$hasAssistant",
24387 "SHA256",
24388 "context-only",
24389 ] {
24390 assert!(script.contains(required), "missing validation: {required}");
24391 }
24392 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24393 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24394 }
24395 assert!(!standard
24396 .get(&kimi)
24397 .unwrap()
24398 .launch
24399 .fixed_args
24400 .iter()
24401 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24402
24403 std::fs::remove_dir_all(root).unwrap();
24404 }
24405
24406 #[cfg(all(feature = "fixture", windows))]
24407 #[tokio::test]
24408 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24409 let root = temporary_workspace_root("kimi-context-only-spawn");
24410 let workspace_root = root.join("workspace");
24411 std::fs::create_dir_all(&workspace_root).unwrap();
24412 let materialization_root = root.join("private-materializations");
24413 let proof_path = root.join("context.proof");
24414 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24415 let workspace_id = WorkspaceId::new("primary").unwrap();
24416 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24417 let endpoint = format!(
24418 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24419 std::process::id(),
24420 unix_time_ms(),
24421 );
24422 let spawn_profiles = SpawnProfileRegistry::new([
24423 crate::protocol::SpawnProfileDefaults {
24424 profile_id: profile_id.clone(),
24425 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24426 provider: agent("kimi"),
24427 mode: SessionMode::Pty,
24428 terminal_size: gate4agent_types::TerminalSize {
24429 rows: 30,
24430 columns: 120,
24431 },
24432 prompt: None,
24433 bundle_id: None,
24434 context_id: None,
24435 environment_profile_id: None,
24436 },
24437 ])
24438 .unwrap();
24439 let mut config = NodeServerConfig::new(
24440 &endpoint,
24441 "fixture-token",
24442 node_id.clone(),
24443 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24444 )
24445 .unwrap()
24446 .with_spawn_profiles(spawn_profiles)
24447 .with_session_environment_materialization(
24448 materialization_root,
24449 Arc::new(FixtureSecretResolver {
24450 deny: Arc::new(AtomicBool::new(false)),
24451 }),
24452 )
24453 .unwrap();
24454 config.fixture_raw_pty_runtime = true;
24455 let server = NodeServer::new_context_only_proof_fixture(
24456 config,
24457 agent("kimi"),
24458 proof_path.clone(),
24459 )
24460 .unwrap();
24461 let shared = Arc::clone(&server.shared);
24462 let shutdown = server.shutdown_handle();
24463
24464 let history = HistorySessionRecord {
24465 session_id: "context-only-source-history".to_owned(),
24466 title: None,
24467 cwd: None,
24468 model: None,
24469 message_count: 2,
24470 completed_turn_count: None,
24471 total_tokens: 0,
24472 messages: vec![
24473 gate4agent_types::HistoryMessageRecord {
24474 role: gate4agent_types::HistoryMessageRole::User,
24475 text: "continue the bounded parent run through native C2".to_owned(),
24476 },
24477 gate4agent_types::HistoryMessageRecord {
24478 role: gate4agent_types::HistoryMessageRole::Assistant,
24479 text: "the exact parent context is ready for the child run".to_owned(),
24480 },
24481 ],
24482 };
24483 let pack = NodeContextPack::export(
24484 ContextPackLineageReceipt {
24485 source_node_id: node_id.clone(),
24486 source_session: SessionAddress {
24487 workspace_id: workspace_id.clone(),
24488 session: SessionKey {
24489 instance_id: AgentInstanceId(71),
24490 generation: SessionGeneration(1),
24491 },
24492 },
24493 source_provider: agent("codex"),
24494 },
24495 &history,
24496 )
24497 .unwrap();
24498 let expected_context_hash = digest(&SHA256, pack.bytes())
24499 .as_ref()
24500 .iter()
24501 .map(|byte| format!("{byte:02x}"))
24502 .collect::<String>();
24503 let context = shared
24504 .context_catalog
24505 .write()
24506 .unwrap_or_else(|poisoned| poisoned.into_inner())
24507 .insert(pack)
24508 .unwrap();
24509 let spec = SpawnSpec {
24510 target: crate::protocol::SpawnTarget {
24511 node_id,
24512 workspace_id: workspace_id.clone(),
24513 worktree_id: None,
24514 },
24515 profile_id,
24516 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24517 "target-kimi-r1",
24518 ).unwrap(),
24519 overrides: crate::protocol::SpawnOverrides {
24520 context_id: crate::protocol::SpawnOverride::Set {
24521 value: context.id.clone(),
24522 },
24523 ..crate::protocol::SpawnOverrides::default()
24524 },
24525 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24526 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24527 required_capabilities: SpawnRequiredCapabilities::default(),
24528 };
24529 let resolved = match shared.resolve_spawn_spec(&spec) {
24530 Ok(resolved) => resolved,
24531 Err(error) => panic!(
24532 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24533 error.code,
24534 error.message,
24535 ),
24536 };
24537 let resolved_context = match shared.resolve_context(&resolved) {
24538 Ok(Some(resolved_context)) => resolved_context,
24539 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24540 Err(error) => panic!(
24541 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24542 error.code,
24543 error.message,
24544 ),
24545 };
24546 assert_eq!(resolved_context, context);
24547 let runtime_policy = match shared
24548 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24549 .await
24550 {
24551 Ok(runtime_policy) => runtime_policy,
24552 Err(error) => panic!(
24553 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24554 error.code,
24555 error.message,
24556 ),
24557 };
24558 assert!(runtime_policy.raw_pty_lifecycle);
24559
24560 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24561 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24562 agent("kimi"),
24563 TransportKind::Pty,
24564 vec![EnvMutation {
24565 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24566 value: Some(OsString::from("must-remain-rejected")),
24567 }],
24568 )
24569 .unwrap();
24570 assert_eq!(
24571 launch_control
24572 .install_native_launch_environment_overlay(
24573 AgentInstanceId(72),
24574 generic_overlay,
24575 )
24576 .unwrap_err(),
24577 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24578 );
24579
24580 let diagnostic_address = SessionAddress {
24581 workspace_id: workspace_id.clone(),
24582 session: SessionKey {
24583 instance_id: AgentInstanceId(73),
24584 generation: SessionGeneration(1),
24585 },
24586 };
24587 let (diagnostic_overlay, diagnostic_materialization) = match shared
24588 .prepare_session_materialization(
24589 &diagnostic_address,
24590 &agent("kimi"),
24591 SessionMode::Pty,
24592 None,
24593 None,
24594 Some(&resolved_context),
24595 None,
24596 )
24597 {
24598 Ok(Some(prepared)) => prepared,
24599 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24600 Err(error) => panic!(
24601 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24602 error.code,
24603 error.message,
24604 ),
24605 };
24606 let diagnostic_overlay = diagnostic_overlay
24607 .expect("Kimi context-only materialization omitted its environment overlay");
24608 assert!(matches!(
24609 &diagnostic_overlay,
24610 PreparedNativeLaunchOverlay::Environment(_),
24611 ));
24612 match shared.install_prepared_launch_overlay(
24613 diagnostic_address.session.instance_id,
24614 diagnostic_overlay,
24615 ) {
24616 Ok(None) => {}
24617 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24618 Err(error) => panic!(
24619 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24620 error.code,
24621 error.message,
24622 ),
24623 }
24624 assert!(launch_control.clear_native_instance_launch_overlay(
24625 diagnostic_address.session.instance_id,
24626 ));
24627 drop(diagnostic_materialization);
24628 assert!(shared
24629 .materializations
24630 .lock()
24631 .unwrap_or_else(|poisoned| poisoned.into_inner())
24632 .is_empty());
24633
24634 let node_task = tokio::spawn(server.run());
24635 let ready_client = timeout(Duration::from_secs(5), async {
24636 loop {
24637 match gate4agent_node_wire::LocalNodeClient::connect(
24638 &endpoint,
24639 &shared.node_id,
24640 ClientRole::Observer,
24641 "fixture-token",
24642 )
24643 .await
24644 {
24645 Ok(client) => break client,
24646 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24647 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24648 }
24649 }
24650 })
24651 .await
24652 .expect("Kimi context-only Node did not become ready");
24653 assert_eq!(
24654 ready_client
24655 .hello()
24656 .snapshot
24657 .provider_runtime_statuses
24658 .iter()
24659 .find(|status| status.provider().as_str() == "kimi")
24660 .expect("Kimi runtime status is missing")
24661 .mode(),
24662 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24663 );
24664 drop(ready_client);
24665
24666 let receipt = match shared.spawn_from_spec(spec).await {
24667 Ok(receipt) => receipt,
24668 Err(error) => panic!(
24669 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24670 error.code,
24671 error.message,
24672 ),
24673 };
24674 assert_eq!(receipt.provider, agent("kimi"));
24675 assert_eq!(receipt.context.as_ref(), Some(&context));
24676 assert!(receipt.context_binding_is_valid());
24677 timeout(Duration::from_secs(5), async {
24678 while !proof_path.is_file() {
24679 sleep(Duration::from_millis(10)).await;
24680 }
24681 })
24682 .await
24683 .expect("Kimi context-only fixture did not write its proof");
24684
24685 let proof = std::fs::read_to_string(&proof_path).unwrap();
24686 let lines = proof.lines().collect::<Vec<_>>();
24687 assert_eq!(lines.len(), 7);
24688 assert_eq!(lines[0], "context-only");
24689 assert!(Path::new(lines[1]).is_absolute());
24690 assert_ne!(
24691 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24692 std::fs::canonicalize(&workspace_root).unwrap(),
24693 );
24694 assert_eq!(
24695 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24696 std::fs::canonicalize(&workspace_root).unwrap(),
24697 );
24698 assert_eq!(lines[3], expected_context_hash);
24699 assert_eq!(lines[4], "g4a-context-pack-v1");
24700 assert_eq!(lines[5], "codex");
24701 assert_eq!(lines[6], "2");
24702 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24703 session.instance_id == receipt.session.session.instance_id
24704 && session.agent_id.as_str() == "kimi"
24705 }));
24706
24707 shutdown.request_shutdown().await.unwrap();
24708 timeout(Duration::from_secs(5), node_task)
24709 .await
24710 .unwrap()
24711 .unwrap()
24712 .unwrap();
24713 drop(shutdown);
24714 drop(shared);
24715 std::fs::remove_dir_all(root).unwrap();
24716 }
24717
24718 #[cfg(feature = "fixture")]
24719 #[test]
24720 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24721 let root = temporary_workspace_root("clean-exit-constructor");
24722 std::fs::create_dir_all(&root).unwrap();
24723 let config = construction_test_config("clean-exit-constructor")
24724 .with_state_path(root.join("node-state.json"))
24725 .unwrap();
24726 let server = NodeServer::new_clean_exit_fixture(
24727 config,
24728 root.clone(),
24729 root.join("started.marker"),
24730 root.join("release.signal"),
24731 )
24732 .unwrap();
24733 assert_eq!(
24734 server
24735 .shared
24736 .enabled_providers
24737 .iter()
24738 .map(AgentId::as_str)
24739 .collect::<Vec<_>>(),
24740 ["claude"],
24741 );
24742 drop(server);
24743 std::fs::remove_dir_all(root).unwrap();
24744 }
24745
24746 #[test]
24747 fn production_registry_provider_contract_manifest_is_exact() {
24748 let registry = active_registry().unwrap();
24749 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24750 assert_eq!(
24751 providers
24752 .iter()
24753 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24754 .collect::<Vec<_>>(),
24755 vec![
24756 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24757 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24758 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24759 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24760 ]
24761 );
24762 assert_eq!(
24763 adapters
24764 .iter()
24765 .map(|contract| (
24766 contract.provider.as_str(),
24767 contract.family,
24768 contract.adapter_id.as_str(),
24769 contract.revision.as_str(),
24770 ))
24771 .collect::<Vec<_>>(),
24772 vec![
24773 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24774 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24775 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24776 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24777 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24778 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24779 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24780 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24781 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24782 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24783 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24784 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24785 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24786 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24787 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24788 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24789 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24790 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24791 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24792 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24793 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24794 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24795 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24796 ]
24797 );
24798 }
24799
24800 fn construction_test_config(name: &str) -> NodeServerConfig {
24801 let workspace = WorkspaceConfig::new(
24802 WorkspaceId::new("primary").unwrap(),
24803 std::env::current_dir().unwrap(),
24804 )
24805 .unwrap();
24806 NodeServerConfig::new(
24807 format!(r"\\.\pipe\gate4agent-{name}"),
24808 "fixture-token",
24809 NodeId::new(name).unwrap(),
24810 [workspace],
24811 )
24812 .unwrap()
24813 }
24814
24815 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24816 let adapter = AdapterBinding::new(
24817 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24818 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24819 gate4agent_types::AdapterVerification::SyntheticFixture,
24820 )
24821 .unwrap();
24822 let agent_ids = ["claude", "codex", "kimi"]
24823 .into_iter()
24824 .map(|agent_id| AgentId::new(agent_id).unwrap())
24825 .collect::<Vec<_>>();
24826 let adapters = gate4agent_catalog::AdapterRegistry::new(
24827 [
24828 AdapterFamily::PtySemantic,
24829 AdapterFamily::Pipe,
24830 AdapterFamily::OneShot,
24831 AdapterFamily::Acp,
24832 AdapterFamily::Hook,
24833 AdapterFamily::ManagedHook,
24834 AdapterFamily::History,
24835 AdapterFamily::Resume,
24836 AdapterFamily::SessionOptions,
24837 AdapterFamily::CapabilityProbe,
24838 ]
24839 .into_iter()
24840 .map(|family| gate4agent_catalog::AdapterDescriptor {
24841 family,
24842 binding: adapter.clone(),
24843 agents: agent_ids.clone(),
24844 }),
24845 )
24846 .unwrap();
24847 let specs = agent_ids
24848 .iter()
24849 .map(|agent_id| {
24850 let agent_id = agent_id.as_str();
24851 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24852 spec.revision =
24853 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24854 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24855 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24856 adapter: adapter.clone(),
24857 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24858 launch_override: None,
24859 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24860 });
24861 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24862 adapter: adapter.clone(),
24863 launch_override: None,
24864 });
24865 spec.capabilities.adapters.hook = Some(adapter.clone());
24866 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24867 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24868 spec.capabilities.adapters.history = Some(adapter.clone());
24869 spec.capabilities.adapters.resume = Some(adapter.clone());
24870 spec.capabilities.adapters.session_options = Some(adapter.clone());
24871 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24872 spec
24873 })
24874 .collect::<Vec<_>>();
24875 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24876 }
24877
24878 #[test]
24886 fn node_server_construction_accepts_the_production_manifest() {
24887 let registry = active_registry().unwrap();
24888 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24889 assert_eq!(providers.len(), registry.iter().count());
24890 assert!(!providers.is_empty());
24891 let server = NodeServer::new_with_registry(
24892 construction_test_config("manifest-production-bound"),
24893 registry,
24894 )
24895 .unwrap();
24896 drop(server);
24897 }
24898
24899 #[test]
24900 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24901 let registry = maximum_size_provider_manifest_registry();
24902 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24903 assert_eq!(providers.len(), 3);
24904 assert_eq!(adapters.len(), 30);
24905 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24906 let error = match NodeServer::new_with_registry(
24907 construction_test_config("manifest-handshake-overflow"),
24908 registry,
24909 ) {
24910 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24911 Err(error) => error,
24912 };
24913 assert!(matches!(
24914 error,
24915 NodeServerError::ProviderContractManifest(message)
24916 if message.contains("authentication binding length")
24917 && message.contains("8192-byte limit")
24918 ));
24919 }
24920
24921 #[test]
24922 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24923 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24924 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24925 let registry = AgentRegistry::new([spec]).unwrap();
24926 assert!(matches!(
24927 provider_contract_manifest(®istry),
24928 Err(NodeServerError::ProviderContractManifest(message))
24929 if message.contains("exceeds the 128-byte limit")
24930 ));
24931 }
24932
24933 #[test]
24934 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24935 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24936 spec.revision = "invalid revision".to_owned();
24937 let registry = AgentRegistry::new([spec]).unwrap();
24938 assert!(matches!(
24939 provider_contract_manifest(®istry),
24940 Err(NodeServerError::ProviderContractManifest(message))
24941 if message.contains("bounded lowercase ASCII")
24942 ));
24943 }
24944
24945 #[test]
24946 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24947 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24948 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24949 let conflicting_binding = builtin_registry()
24950 .get_by_id("codex")
24951 .unwrap()
24952 .capabilities
24953 .adapters
24954 .one_shot
24955 .clone()
24956 .unwrap();
24957 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24958 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24959 pipe.adapter = transport_binding;
24960 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24961 let registry = AgentRegistry::new([spec]).unwrap();
24962 assert!(matches!(
24963 provider_contract_manifest(®istry),
24964 Err(NodeServerError::ProviderContractManifest(message))
24965 if message.contains("conflicting OneShot adapter bindings")
24966 ));
24967 }
24968
24969 #[test]
24970 fn public_node_failures_expose_only_fixed_categories() {
24971 let provider = failure(
24972 NodeFailureCode::BackendOperationFailed,
24973 r"provider bearer-secret failed at C:\private\session.jsonl",
24974 );
24975 assert_eq!(provider.message, "backend-operation-failed");
24976 assert!(!provider.message.contains("bearer-secret"));
24977 assert!(!provider.message.contains(r"C:\private"));
24978 let persistence = persistence_failure(io::Error::new(
24979 io::ErrorKind::Other,
24980 r"state write failed at C:\private\state-v1.json",
24981 ));
24982 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24983 assert!(!persistence.message.contains(r"C:\private"));
24984
24985 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24986 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24987 assert_eq!(
24988 generic.to_string(),
24989 "node durable state failed: durable-state-load-failed",
24990 );
24991 }
24992 }
24993
24994 #[test]
24995 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24996 let sequence = b"\x1b[1;5D".to_vec();
24997 assert_eq!(
24998 terminal_bytes_action(sequence.clone()).unwrap(),
24999 InputAction::TerminalBytes(sequence),
25000 );
25001 assert_eq!(
25002 terminal_bytes_action(Vec::new()).unwrap_err().code,
25003 NodeFailureCode::InvalidRequest,
25004 );
25005 assert_eq!(
25006 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25007 .unwrap_err()
25008 .code,
25009 NodeFailureCode::InvalidRequest,
25010 );
25011 }
25012
25013 #[test]
25014 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25015 let root = temporary_workspace_root("exclusive-state-owner");
25016 std::fs::create_dir_all(&root).unwrap();
25017 let workspace = WorkspaceConfig::new(
25018 WorkspaceId::new("primary").unwrap(),
25019 &root,
25020 )
25021 .unwrap();
25022 let state_path = root.join("state-v1.json");
25023 let endpoint = format!(
25024 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25025 std::process::id(),
25026 std::time::SystemTime::now()
25027 .duration_since(std::time::UNIX_EPOCH)
25028 .unwrap()
25029 .as_nanos(),
25030 );
25031 let config = NodeServerConfig::new(
25032 endpoint,
25033 "fixture-token",
25034 NodeId::new("node-1").unwrap(),
25035 [workspace],
25036 )
25037 .unwrap()
25038 .with_state_path(&state_path)
25039 .unwrap();
25040
25041 let first = NodeServer::new(config.clone()).unwrap();
25042 let error = match NodeServer::new(config.clone()) {
25043 Ok(_) => panic!("a second node server acquired the same durable state path"),
25044 Err(error) => error,
25045 };
25046 assert!(matches!(
25047 &error,
25048 NodeServerError::DurableState(error)
25049 if error.kind() == io::ErrorKind::WouldBlock
25050 ));
25051 assert_eq!(
25052 error.to_string(),
25053 "node durable state failed: durable-state-lock-failed",
25054 );
25055 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25056 drop(first);
25057 let second = NodeServer::new(config).unwrap();
25058 drop(second);
25059 std::fs::remove_dir_all(root).unwrap();
25060 }
25061
25062 #[test]
25063 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25064 let catalog = active_registry().unwrap();
25065 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25066 let workspace_id = WorkspaceId::new("primary").unwrap();
25067 let workspace = WorkspaceConfig::new(
25068 workspace_id.clone(),
25069 std::env::current_dir().unwrap(),
25070 )
25071 .unwrap();
25072 let shared = NodeShared::new(
25073 handle,
25074 "fixture-token".to_owned(),
25075 NodeId::new("node-1").unwrap(),
25076 vec![workspace.clone()],
25077 vec![agent("claude"), agent("codex"), agent("kimi")],
25078 );
25079 let instance_id = AgentInstanceId(17);
25080 let generation = SessionGeneration(3);
25081 assert!(shared.address_for(instance_id, generation).is_none());
25082 shared.bind_session(instance_id, workspace_id.clone(), generation);
25083 let address = SessionAddress {
25084 workspace_id,
25085 session: SessionKey { instance_id, generation },
25086 };
25087 assert_eq!(
25088 shared.workspace_root(&address.workspace_id).unwrap(),
25089 workspace.canonical_root,
25090 );
25091 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25092 shared.remove_binding(&address);
25093 assert!(shared.address_for(instance_id, generation).is_none());
25094 }
25095
25096 #[test]
25097 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25098 let address = SessionAddress {
25099 workspace_id: WorkspaceId::new("primary").unwrap(),
25100 session: SessionKey {
25101 instance_id: AgentInstanceId(7),
25102 generation: SessionGeneration(2),
25103 },
25104 };
25105 let record_id = SessionRecordId::new("record-a").unwrap();
25106 let size = gate4agent_types::TerminalSize {
25107 rows: 24,
25108 columns: 80,
25109 };
25110 let address_requests = [
25111 NodeRequest::Resume {
25112 session: address.clone(),
25113 terminal_size: size,
25114 initial_prompt: None,
25115 },
25116 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25117 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25118 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25119 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25120 NodeRequest::TerminalControl {
25121 session: address.clone(),
25122 control: TerminalControl::Enter,
25123 },
25124 NodeRequest::Resize { session: address.clone(), size },
25125 NodeRequest::Interrupt { session: address.clone() },
25126 NodeRequest::Stop { session: address.clone(), force: false },
25127 NodeRequest::Remove { session: address },
25128 ];
25129 for request in &address_requests {
25130 assert!(request_requires_open_provider_ids_with(
25131 request,
25132 |_| Some(agent("third-party-agent")),
25133 |_| Some(agent("claude")),
25134 ));
25135 assert!(!request_requires_open_provider_ids_with(
25136 request,
25137 |_| Some(agent("claude")),
25138 |_| Some(agent("claude")),
25139 ));
25140 assert!(request_requires_open_provider_ids_with(
25141 request,
25142 |_| None,
25143 |_| Some(agent("claude")),
25144 ));
25145 }
25146
25147 let record_requests = [
25148 NodeRequest::RenameSessionRecord {
25149 record_id: record_id.clone(),
25150 display_name: "renamed".to_owned(),
25151 },
25152 NodeRequest::ResumeSessionRecord {
25153 record_id: record_id.clone(),
25154 terminal_size: size,
25155 initial_prompt: None,
25156 },
25157 NodeRequest::ForgetSessionRecord { record_id },
25158 ];
25159 for request in &record_requests {
25160 assert!(request_requires_open_provider_ids_with(
25161 request,
25162 |_| Some(agent("claude")),
25163 |_| Some(agent("grok")),
25164 ));
25165 assert!(!request_requires_open_provider_ids_with(
25166 request,
25167 |_| Some(agent("claude")),
25168 |_| Some(agent("kimi")),
25169 ));
25170 assert!(request_requires_open_provider_ids_with(
25171 request,
25172 |_| Some(agent("claude")),
25173 |_| None,
25174 ));
25175 }
25176
25177 assert!(request_requires_open_provider_ids_with(
25178 &NodeRequest::Spawn {
25179 workspace_id: WorkspaceId::new("primary").unwrap(),
25180 provider: agent("third-party-agent"),
25181 mode: SessionMode::Pty,
25182 terminal_size: size,
25183 initial_prompt: None,
25184 },
25185 |_| None,
25186 |_| None,
25187 ));
25188 assert!(!request_requires_open_provider_ids_with(
25189 &NodeRequest::Snapshot,
25190 |_| None,
25191 |_| None,
25192 ));
25193 assert!(request_requires_open_provider_ids_with(
25194 &NodeRequest::ForgetContextPack {
25195 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25196 },
25197 |_| None,
25198 |_| None,
25199 ));
25200 }
25201
25202 #[test]
25203 fn legacy_projection_filters_open_record_replies_and_event_history() {
25204 let catalog = active_registry().unwrap();
25205 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25206 let workspace = WorkspaceConfig::new(
25207 WorkspaceId::new("primary").unwrap(),
25208 std::env::current_dir().unwrap(),
25209 )
25210 .unwrap();
25211 let shared = NodeShared::new(
25212 handle,
25213 "fixture-token".to_owned(),
25214 NodeId::new("node-1").unwrap(),
25215 vec![workspace],
25216 vec![agent("claude"), agent("grok")],
25217 );
25218 let legacy = record("claude", "legacy-record");
25219 let open = record("grok", "open-record");
25220 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25221 record: legacy.clone(),
25222 });
25223 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25224 record: open.clone(),
25225 });
25226 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25227 record_id: legacy.record_id.clone(),
25228 });
25229 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25230 record_id: open.record_id.clone(),
25231 });
25232 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25233 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25234 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25235 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25236
25237 let mut record_reply = ResponseEnvelope {
25238 request_id: 1,
25239 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25240 };
25241 project_response_legacy_provider_ids(&shared, &mut record_reply);
25242 assert!(matches!(
25243 record_reply.result,
25244 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25245 ));
25246
25247 let mut resync = ResponseEnvelope {
25248 request_id: 2,
25249 result: Ok(NodeResponse::Resync {
25250 event_sequence: open_removed.sequence,
25251 oldest_available_sequence: 1,
25252 snapshot: shared.snapshot(),
25253 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25254 }),
25255 };
25256 project_response_legacy_provider_ids(&shared, &mut resync);
25257 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25258 panic!("legacy projection changed the resync response kind");
25259 };
25260 assert!(snapshot.session_records.iter().all(|item| {
25261 provider_id_is_legacy(&item.provider)
25262 }));
25263 assert_eq!(events.len(), 2);
25264 assert!(events.iter().all(|event| matches!(
25265 &event.event,
25266 NodeEvent::SessionRecordUpserted { record }
25267 if provider_id_is_legacy(&record.provider)
25268 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25269 }
25270
25271 #[test]
25272 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25273 let catalog = active_registry().unwrap();
25274 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25275 let workspace_id = WorkspaceId::new("primary").unwrap();
25276 let workspace = WorkspaceConfig::new(
25277 workspace_id.clone(),
25278 std::env::current_dir().unwrap(),
25279 )
25280 .unwrap();
25281 let shared = NodeShared::new(
25282 handle,
25283 "fixture-token".to_owned(),
25284 NodeId::new("node-1").unwrap(),
25285 vec![workspace],
25286 vec![agent("claude")],
25287 );
25288 for index in 0..CONTROL_SESSIONS_MAX {
25289 shared.bind_session(
25290 AgentInstanceId((index + 1) as u64),
25291 workspace_id.clone(),
25292 SessionGeneration(1),
25293 );
25294 }
25295 let error = shared.ensure_binding_capacity().unwrap_err();
25296 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25297 }
25298
25299 #[tokio::test]
25300 async fn provider_spawn_admission_rejects_before_session_mutation() {
25301 let catalog = active_registry().unwrap();
25302 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25303 let workspace = WorkspaceConfig::new(
25304 WorkspaceId::new("primary").unwrap(),
25305 std::env::current_dir().unwrap(),
25306 )
25307 .unwrap();
25308 let mut shared = NodeShared::new(
25309 handle,
25310 "fixture-token".to_owned(),
25311 NodeId::new("node-provider-admission").unwrap(),
25312 vec![workspace],
25313 vec![agent("claude")],
25314 );
25315 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25316 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25317 ])
25318 .unwrap();
25319 let terminal_size = gate4agent_types::TerminalSize {
25320 rows: 24,
25321 columns: 80,
25322 };
25323
25324 let unavailable = shared
25325 .spawn_session(
25326 WorkspaceId::new("primary").unwrap(),
25327 agent("claude"),
25328 SessionMode::Pty,
25329 terminal_size,
25330 None,
25331 )
25332 .await
25333 .unwrap_err();
25334 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25335 assert_eq!(unavailable.message, "backend-operation-failed");
25336
25337 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25338 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25339 agent("claude"),
25340 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25341 ),
25342 ])
25343 .unwrap();
25344 let semantic = shared
25345 .spawn_session(
25346 WorkspaceId::new("primary").unwrap(),
25347 agent("claude"),
25348 SessionMode::Inline,
25349 terminal_size,
25350 None,
25351 )
25352 .await
25353 .unwrap_err();
25354 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25355 assert_eq!(semantic.message, "unsupported-capability");
25356
25357 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25358 assert!(shared
25359 .session_bindings
25360 .lock()
25361 .unwrap_or_else(|poisoned| poisoned.into_inner())
25362 .is_empty());
25363 assert!(shared
25364 .session_records
25365 .lock()
25366 .unwrap_or_else(|poisoned| poisoned.into_inner())
25367 .records
25368 .is_empty());
25369 assert!(shared.handle.snapshot().sessions.is_empty());
25370 }
25371
25372 #[tokio::test]
25373 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25374 let catalog = active_registry().unwrap();
25375 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25376 let workspace_id = WorkspaceId::new("primary").unwrap();
25377 let workspace = WorkspaceConfig::new(
25378 workspace_id.clone(),
25379 std::env::current_dir().unwrap(),
25380 )
25381 .unwrap();
25382 let shared = NodeShared::new(
25383 handle,
25384 "fixture-token".to_owned(),
25385 NodeId::new("node-raw-spawn-record").unwrap(),
25386 vec![workspace],
25387 vec![agent("claude")],
25388 );
25389 let address = SessionAddress {
25390 workspace_id,
25391 session: SessionKey {
25392 instance_id: AgentInstanceId(71),
25393 generation: SessionGeneration::default(),
25394 },
25395 };
25396
25397 let record_id = shared
25398 .bind_spawn_session(
25399 &address,
25400 agent("claude"),
25401 SessionMode::Pty,
25402 ProviderRuntimePolicy::raw_pty(),
25403 SpawnRecordPolicy::Always,
25404 None,
25405 )
25406 .unwrap()
25407 .unwrap();
25408 shared
25409 .handle
25410 .dispatch(shared.prepare_command(ControlCommand::Register {
25411 instance_id: address.session.instance_id,
25412 agent_id: agent("claude"),
25413 transport: TransportKind::Pty,
25414 }))
25415 .unwrap();
25416 runtime.tick().await;
25417
25418 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25419 session.instance_id == address.session.instance_id
25420 && session.generation == address.session.generation
25421 }));
25422 let record = shared.record(&record_id).unwrap();
25423 assert_eq!(record.display_name, "claude #1");
25424 assert_eq!(record.state, ManagedSessionState::Live);
25425 assert_eq!(record.active_session.as_ref(), Some(&address));
25426 assert!(record.provider_session.is_none());
25427 let binding_record_id = shared
25428 .session_bindings
25429 .lock()
25430 .unwrap_or_else(|poisoned| poisoned.into_inner())
25431 .get(&address.session.instance_id)
25432 .and_then(|binding| binding.record_id.clone());
25433 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25434
25435 let renamed = shared
25436 .rename_session_record(&record_id, "raw review".to_owned())
25437 .unwrap();
25438 assert_eq!(renamed.record_id, record_id);
25439 assert_eq!(renamed.display_name, "raw review");
25440 shared.reconcile_managed_record(
25441 &record_id,
25442 &address,
25443 &ControlEvent {
25444 sequence: 1,
25445 command_id: None,
25446 instance_id: address.session.instance_id,
25447 generation: address.session.generation,
25448 event: ControlEventKind::Running { process_id: None },
25449 },
25450 false,
25451 false,
25452 );
25453 let running = shared.record(&record_id).unwrap();
25454 assert_eq!(running.state, ManagedSessionState::Live);
25455 assert_eq!(running.active_session.as_ref(), Some(&address));
25456 assert_eq!(running.display_name, "raw review");
25457
25458 shared.reconcile_managed_record(
25459 &record_id,
25460 &address,
25461 &ControlEvent {
25462 sequence: 2,
25463 command_id: None,
25464 instance_id: address.session.instance_id,
25465 generation: address.session.generation,
25466 event: ControlEventKind::Exited {
25467 exit_code: Some(0),
25468 forced: false,
25469 },
25470 },
25471 false,
25472 false,
25473 );
25474 let detached = shared.record(&record_id).unwrap();
25475 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25476 assert!(detached.active_session.is_none());
25477 assert_eq!(detached.display_name, "raw review");
25478 let renamed_detached = shared
25479 .rename_session_record(&record_id, "raw review detached".to_owned())
25480 .unwrap();
25481 assert_eq!(renamed_detached.record_id, record_id);
25482 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25483 assert!(renamed_detached.active_session.is_none());
25484 assert_eq!(renamed_detached.display_name, "raw review detached");
25485 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25486 }
25487
25488 #[test]
25489 fn legacy_raw_spawn_binding_remains_untracked() {
25490 let catalog = active_registry().unwrap();
25491 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25492 let workspace_id = WorkspaceId::new("primary").unwrap();
25493 let workspace = WorkspaceConfig::new(
25494 workspace_id.clone(),
25495 std::env::current_dir().unwrap(),
25496 )
25497 .unwrap();
25498 let shared = NodeShared::new(
25499 handle,
25500 "fixture-token".to_owned(),
25501 NodeId::new("node-legacy-raw-spawn").unwrap(),
25502 vec![workspace],
25503 vec![agent("claude")],
25504 );
25505 let address = SessionAddress {
25506 workspace_id,
25507 session: SessionKey {
25508 instance_id: AgentInstanceId(72),
25509 generation: SessionGeneration::default(),
25510 },
25511 };
25512
25513 let record_id = shared
25514 .bind_spawn_session(
25515 &address,
25516 agent("claude"),
25517 SessionMode::Pty,
25518 ProviderRuntimePolicy::raw_pty(),
25519 SpawnRecordPolicy::ProviderIdentityOnly,
25520 None,
25521 )
25522 .unwrap();
25523
25524 assert!(record_id.is_none());
25525 let binding_record_id = shared
25526 .session_bindings
25527 .lock()
25528 .unwrap_or_else(|poisoned| poisoned.into_inner())
25529 .get(&address.session.instance_id)
25530 .and_then(|binding| binding.record_id.clone());
25531 assert!(binding_record_id.is_none());
25532 assert!(shared.snapshot().session_records.is_empty());
25533 }
25534
25535 #[tokio::test]
25536 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25537 let catalog = active_registry().unwrap();
25538 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25539 let workspace_id = WorkspaceId::new("primary").unwrap();
25540 let workspace = WorkspaceConfig::new(
25541 workspace_id.clone(),
25542 std::env::current_dir().unwrap(),
25543 )
25544 .unwrap();
25545 let mut shared = NodeShared::new(
25546 handle,
25547 "fixture-token".to_owned(),
25548 NodeId::new("node-raw-semantic-gate").unwrap(),
25549 vec![workspace],
25550 vec![agent("claude")],
25551 );
25552 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25553 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25554 ])
25555 .unwrap();
25556 shared
25557 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25558 .unwrap();
25559 let address = SessionAddress {
25560 workspace_id,
25561 session: SessionKey {
25562 instance_id: AgentInstanceId(72),
25563 generation: SessionGeneration::default(),
25564 },
25565 };
25566 shared.bind_session_with_policy(
25567 &address,
25568 ProviderRuntimePolicy::raw_pty(),
25569 None,
25570 );
25571 shared
25572 .handle
25573 .dispatch(shared.prepare_command(ControlCommand::Register {
25574 instance_id: address.session.instance_id,
25575 agent_id: agent("claude"),
25576 transport: TransportKind::Pty,
25577 }))
25578 .unwrap();
25579 runtime.tick().await;
25580 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25581 let revision_before = shared.handle.snapshot().revision;
25582 let history_sequence_before = shared.current_sequence();
25583 let terminal_size = gate4agent_types::TerminalSize {
25584 rows: 24,
25585 columns: 80,
25586 };
25587
25588 for request in [
25589 NodeRequest::Prompt {
25590 session: address.clone(),
25591 text: "prompt".to_owned(),
25592 },
25593 NodeRequest::Paste {
25594 session: address.clone(),
25595 text: "paste".to_owned(),
25596 },
25597 NodeRequest::Resume {
25603 session: address.clone(),
25604 terminal_size,
25605 initial_prompt: Some("resume".to_owned()),
25606 },
25607 ] {
25608 let error = process_request_inner(
25609 &shared,
25610 77,
25611 ClientRole::Operator,
25612 request,
25613 )
25614 .await
25615 .unwrap_err();
25616 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25617 }
25618
25619 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25620 assert_eq!(shared.handle.snapshot().revision, revision_before);
25621 assert_eq!(shared.current_sequence(), history_sequence_before);
25622 let bindings = shared
25623 .session_bindings
25624 .lock()
25625 .unwrap_or_else(|poisoned| poisoned.into_inner());
25626 let binding = bindings.get(&address.session.instance_id).unwrap();
25627 assert_eq!(binding.generation, address.session.generation);
25628 assert!(binding.pending_resume.is_none());
25629 assert!(binding.record_id.is_none());
25630 }
25631
25632 #[tokio::test]
25633 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25634 let catalog = active_registry().unwrap();
25635 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25636 let primary = WorkspaceConfig::new(
25637 WorkspaceId::new("primary").unwrap(),
25638 std::env::current_dir().unwrap(),
25639 )
25640 .unwrap();
25641 let shared = NodeShared::new(
25642 handle,
25643 "fixture-token".to_owned(),
25644 NodeId::new("node-1").unwrap(),
25645 vec![primary],
25646 vec![agent("claude")],
25647 );
25648 shared
25649 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25650 .unwrap();
25651 let before = shared.snapshot();
25652 let error = process_request_inner(
25653 &shared,
25654 77,
25655 ClientRole::Operator,
25656 NodeRequest::RegisterWorkspace {
25657 workspace_id: WorkspaceId::new("foreign").unwrap(),
25658 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25659 },
25660 )
25661 .await
25662 .unwrap_err();
25663 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25664 assert_eq!(error.message, "invalid-request");
25665 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25666 }
25667
25668 #[tokio::test]
25669 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25670 let catalog = active_registry().unwrap();
25671 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25672 let primary_id = WorkspaceId::new("primary").unwrap();
25673 let primary = WorkspaceConfig::new(
25674 primary_id.clone(),
25675 std::env::current_dir().unwrap(),
25676 )
25677 .unwrap();
25678 let shared = NodeShared::new(
25679 handle,
25680 "fixture-token".to_owned(),
25681 NodeId::new("node-1").unwrap(),
25682 vec![primary.clone()],
25683 vec![agent("claude")],
25684 );
25685 let secondary_id = WorkspaceId::new("secondary").unwrap();
25686 let secondary = shared
25687 .register_workspace(
25688 secondary_id.clone(),
25689 std::env::temp_dir().to_string_lossy().into_owned(),
25690 )
25691 .await
25692 .unwrap();
25693 assert_eq!(secondary.workspace_id, secondary_id);
25694 assert!(secondary.sessions.is_empty());
25695 assert_eq!(shared.snapshot().workspaces.len(), 2);
25696
25697 let duplicate_id = shared
25698 .register_workspace(
25699 secondary_id.clone(),
25700 primary.canonical_root().to_owned(),
25701 )
25702 .await
25703 .unwrap_err();
25704 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25705 let duplicate_root = shared
25706 .register_workspace(
25707 WorkspaceId::new("duplicate-root").unwrap(),
25708 windows_path_text(&secondary.canonical_root).to_owned(),
25709 )
25710 .await
25711 .unwrap_err();
25712 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25713
25714 let address = SessionAddress {
25715 workspace_id: secondary_id.clone(),
25716 session: SessionKey {
25717 instance_id: AgentInstanceId(41),
25718 generation: SessionGeneration(1),
25719 },
25720 };
25721 shared.bind_session(
25722 address.session.instance_id,
25723 secondary_id.clone(),
25724 address.session.generation,
25725 );
25726 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25727 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25728 shared.remove_binding(&address);
25729 shared.unregister_workspace(&secondary_id).unwrap();
25730 assert_eq!(shared.snapshot().workspaces.len(), 1);
25731
25732 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25733 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25734 let history = shared
25735 .history
25736 .lock()
25737 .unwrap_or_else(|poisoned| poisoned.into_inner());
25738 assert!(matches!(
25739 &history.events[0],
25740 NodeEventEnvelope {
25741 sequence: 1,
25742 event: NodeEvent::WorkspaceAdded { workspace },
25743 } if workspace.workspace_id == secondary_id
25744 ));
25745 assert!(matches!(
25746 &history.events[1],
25747 NodeEventEnvelope {
25748 sequence: 2,
25749 event: NodeEvent::WorkspaceRemoved { workspace_id },
25750 } if workspace_id == &secondary_id
25751 ));
25752 }
25753
25754 #[tokio::test]
25755 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25756 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25757 std::fs::create_dir_all(&secondary_root).unwrap();
25758 let catalog = active_registry().unwrap();
25759 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25760 let primary_id = WorkspaceId::new("primary").unwrap();
25761 let secondary_id = WorkspaceId::new("secondary").unwrap();
25762 let shared = NodeShared::new(
25763 handle,
25764 "fixture-token".to_owned(),
25765 NodeId::new("node-session-reference-index").unwrap(),
25766 vec![
25767 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25768 .unwrap(),
25769 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25770 ],
25771 vec![agent("claude"), agent("codex")],
25772 );
25773 let identity = ProviderSessionIdentity {
25774 key: gate4agent_types::ProviderSessionKey::SessionId,
25775 id: "provider-session-42".to_owned(),
25776 transcript_path: None,
25777 };
25778 let index_request = |workspace_id: WorkspaceId,
25779 provider: AgentId,
25780 identity: ProviderSessionIdentity,
25781 display_name: &str| {
25782 NodeRequest::IndexProviderSession {
25783 workspace_id,
25784 provider,
25785 identity,
25786 display_name: display_name.to_owned(),
25787 }
25788 };
25789 let request = index_request(
25790 primary_id.clone(),
25791 agent("claude"),
25792 identity.clone(),
25793 "release shepherd",
25794 );
25795 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25796 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25797 }));
25798 let observer = process_request_inner(
25799 &shared,
25800 77,
25801 ClientRole::Observer,
25802 request.clone(),
25803 )
25804 .await
25805 .unwrap_err();
25806 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25807 let uncontrolled = process_request_inner(
25808 &shared,
25809 77,
25810 ClientRole::Operator,
25811 request.clone(),
25812 )
25813 .await
25814 .unwrap_err();
25815 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25816 shared
25817 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25818 .unwrap();
25819
25820 let indexed = process_request_inner(
25821 &shared,
25822 77,
25823 ClientRole::Operator,
25824 request.clone(),
25825 )
25826 .await
25827 .unwrap();
25828 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25829 panic!("provider session reference index returned another response");
25830 };
25831 assert_eq!(record.state, ManagedSessionState::Dormant);
25832 assert_eq!(record.mode, SessionMode::Pty);
25833 assert_eq!(record.workspace_id, primary_id);
25834 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25835 assert!(record.active_session.is_none());
25836 assert!(record.environment_profile.is_none());
25837 assert!(record.bundle.is_none());
25838 assert!(record.context.is_none());
25839
25840 let repeated = process_request_inner(
25841 &shared,
25842 77,
25843 ClientRole::Operator,
25844 index_request(
25845 primary_id.clone(),
25846 agent("claude"),
25847 identity.clone(),
25848 "ignored replacement",
25849 ),
25850 )
25851 .await
25852 .unwrap();
25853 assert!(matches!(
25854 repeated,
25855 NodeResponse::ProviderSessionIndexed { record: ref existing }
25856 if existing.record_id == record.record_id
25857 && existing.display_name == "release shepherd"
25858 ));
25859 let conflicting_workspace = process_request_inner(
25860 &shared,
25861 77,
25862 ClientRole::Operator,
25863 index_request(
25864 secondary_id,
25865 agent("claude"),
25866 identity.clone(),
25867 "release shepherd",
25868 ),
25869 )
25870 .await
25871 .unwrap_err();
25872 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25873 let conflicting_provider = process_request_inner(
25874 &shared,
25875 77,
25876 ClientRole::Operator,
25877 index_request(
25878 primary_id.clone(),
25879 agent("codex"),
25880 identity.clone(),
25881 "release shepherd",
25882 ),
25883 )
25884 .await
25885 .unwrap_err();
25886 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25887 let transcript_rejected = process_request_inner(
25888 &shared,
25889 77,
25890 ClientRole::Operator,
25891 index_request(
25892 primary_id,
25893 agent("claude"),
25894 ProviderSessionIdentity {
25895 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25896 ..identity
25897 },
25898 "release shepherd",
25899 ),
25900 )
25901 .await
25902 .unwrap_err();
25903 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25904 let upserts = shared
25905 .history
25906 .lock()
25907 .unwrap_or_else(|poisoned| poisoned.into_inner())
25908 .events
25909 .iter()
25910 .filter(|event| matches!(
25911 &event.event,
25912 NodeEvent::SessionRecordUpserted { record: published }
25913 if published.record_id == record.record_id
25914 ))
25915 .count();
25916 assert_eq!(upserts, 1);
25917 std::fs::remove_dir_all(secondary_root).unwrap();
25918 }
25919
25920 #[tokio::test]
25921 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25922 let fixture_root = temporary_workspace_root("standalone-independent");
25923 let target = fixture_root.join("independent");
25924 std::fs::create_dir_all(&fixture_root).unwrap();
25925 let catalog = active_registry().unwrap();
25926 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25927 let selected = WorkspaceConfig::new(
25928 WorkspaceId::new("primary").unwrap(),
25929 std::env::current_dir().unwrap(),
25930 )
25931 .unwrap();
25932 let selected_root = selected.canonical_root().to_owned();
25933 let shared = NodeShared::new(
25934 handle,
25935 "fixture-token".to_owned(),
25936 NodeId::new("node-standalone").unwrap(),
25937 vec![selected],
25938 vec![agent("claude")],
25939 );
25940 let workspace_id = WorkspaceId::new("independent").unwrap();
25941 let request = NodeRequest::CreateStandaloneWorkspace {
25942 workspace_id: workspace_id.clone(),
25943 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25944 initial_branch: Some("main".to_owned()),
25945 };
25946 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25947 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25948 }));
25949 assert!(!request_uses_unnegotiated_capability(
25950 &request,
25951 &baseline_capabilities().unwrap(),
25952 ));
25953 assert!(request_uses_unnegotiated_capability(&request, &[]));
25954
25955 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25956 .await
25957 .unwrap_err();
25958 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25959 assert!(!target.exists());
25960 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25961 .await
25962 .unwrap_err();
25963 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25964 assert!(!target.exists());
25965 shared
25966 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25967 .unwrap();
25968
25969 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25970 .await
25971 .unwrap();
25972 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25973 panic!("standalone workspace creation returned another response");
25974 };
25975 assert_eq!(workspace.workspace_id, workspace_id);
25976 assert!(target.join(".git").is_dir());
25977 let branch = run_git_bounded(
25978 target.to_str().unwrap(),
25979 &["branch", "--show-current"],
25980 GIT_OUTPUT_MAX_BYTES,
25981 )
25982 .await
25983 .unwrap();
25984 assert!(branch.success);
25985 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25986 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25987 assert_eq!(worktrees.len(), 1);
25988 let listed_root = WorkspaceConfig::new(
25989 WorkspaceId::new("listed-independent").unwrap(),
25990 &worktrees[0].path,
25991 )
25992 .unwrap();
25993 assert!(platform::roots_equal(
25994 listed_root.canonical_root(),
25995 windows_path_text(&workspace.canonical_root),
25996 ));
25997 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25998 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25999 snapshot.workspace_id == workspace_id
26000 && snapshot.canonical_root == workspace.canonical_root
26001 }));
26002 assert!(shared
26003 .history
26004 .lock()
26005 .unwrap_or_else(|poisoned| poisoned.into_inner())
26006 .events
26007 .iter()
26008 .any(|event| matches!(
26009 &event.event,
26010 NodeEvent::WorkspaceAdded { workspace }
26011 if workspace.workspace_id == workspace_id
26012 )));
26013 std::fs::remove_dir_all(fixture_root).unwrap();
26014 }
26015
26016 #[tokio::test]
26017 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26018 let fixture_root = temporary_workspace_root("standalone-compensation");
26019 let created_target = fixture_root.join("created-target");
26020 let existing_target = fixture_root.join("existing-target");
26021 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26022 let invalid_state_path = invalid_state_parent.join("state.json");
26023 std::fs::create_dir_all(&existing_target).unwrap();
26024 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26025 let catalog = active_registry().unwrap();
26026 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26027 let primary = WorkspaceConfig::new(
26028 WorkspaceId::new("primary").unwrap(),
26029 std::env::current_dir().unwrap(),
26030 )
26031 .unwrap();
26032 let mut shared = NodeShared::new(
26033 handle,
26034 "fixture-token".to_owned(),
26035 NodeId::new("node-standalone-compensation").unwrap(),
26036 vec![primary],
26037 vec![agent("claude")],
26038 );
26039 shared.state_path = Some(invalid_state_path);
26040
26041 let created_recovery = shared
26042 .create_standalone_workspace(
26043 WorkspaceId::new("compensated").unwrap(),
26044 created_target.to_string_lossy().into_owned(),
26045 Some("main".to_owned()),
26046 )
26047 .await
26048 .unwrap_err();
26049 assert_eq!(
26050 created_recovery.code,
26051 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26052 );
26053 assert!(created_target.join(".git").is_dir());
26054 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26055 workspace.workspace_id.as_str() != "compensated"
26056 }));
26057
26058 let existing_recovery = shared
26059 .create_standalone_workspace(
26060 WorkspaceId::new("restored").unwrap(),
26061 existing_target.to_string_lossy().into_owned(),
26062 None,
26063 )
26064 .await
26065 .unwrap_err();
26066 assert_eq!(
26067 existing_recovery.code,
26068 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26069 );
26070 assert!(existing_target.is_dir());
26071 assert!(existing_target.join(".git").is_dir());
26072 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26073 workspace.workspace_id.as_str() != "restored"
26074 }));
26075
26076 let registered_empty = fixture_root.join("registered-empty");
26077 std::fs::create_dir(®istered_empty).unwrap();
26078 let catalog = active_registry().unwrap();
26079 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26080 let registered = WorkspaceConfig::new(
26081 WorkspaceId::new("registered-empty").unwrap(),
26082 ®istered_empty,
26083 )
26084 .unwrap();
26085 let duplicate_shared = NodeShared::new(
26086 handle,
26087 "fixture-token".to_owned(),
26088 NodeId::new("node-standalone-duplicate-root").unwrap(),
26089 vec![registered],
26090 vec![agent("claude")],
26091 );
26092 let duplicate = duplicate_shared
26093 .create_standalone_workspace(
26094 WorkspaceId::new("duplicate-root").unwrap(),
26095 registered_empty.to_string_lossy().into_owned(),
26096 None,
26097 )
26098 .await
26099 .unwrap_err();
26100 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26101 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26102 std::fs::remove_dir_all(fixture_root).unwrap();
26103 }
26104
26105 #[test]
26106 fn managed_record_id_collision_preserves_the_existing_record() {
26107 let catalog = active_registry().unwrap();
26108 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26109 let workspace = WorkspaceConfig::new(
26110 WorkspaceId::new("primary").unwrap(),
26111 std::env::current_dir().unwrap(),
26112 )
26113 .unwrap();
26114 let shared = NodeShared::new(
26115 handle,
26116 "fixture-token".to_owned(),
26117 NodeId::new("node-1").unwrap(),
26118 vec![workspace.clone()],
26119 vec![agent("claude")],
26120 );
26121 let record_id = SessionRecordId::new("sr-collision").unwrap();
26122 let original = ManagedSessionRecord {
26123 record_id: record_id.clone(),
26124 display_name: "original".to_owned(),
26125 provider: agent("claude"),
26126 mode: SessionMode::Pty,
26127 state: ManagedSessionState::Dormant,
26128 workspace_id: workspace.workspace_id,
26129 canonical_root: opaque_windows_path(workspace.canonical_root),
26130 provider_session: None,
26131 active_session: None,
26132 environment_profile: None,
26133 bundle: None,
26134 context_id: None,
26135 context: None,
26136 exported_context: None,
26137 task_binding: None,
26138 created_at_unix_ms: 1,
26139 updated_at_unix_ms: 1,
26140 last_error: None,
26141 };
26142 shared.insert_record(original.clone()).unwrap();
26143 let mut collision = original.clone();
26144 collision.display_name = "replacement".to_owned();
26145 let error = shared.insert_record(collision).unwrap_err();
26146 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26147 assert_eq!(shared.record(&record_id).unwrap(), original);
26148 }
26149
26150 #[test]
26151 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26152 let catalog = active_registry().unwrap();
26153 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26154 let workspace = WorkspaceConfig::new(
26155 WorkspaceId::new("primary").unwrap(),
26156 std::env::current_dir().unwrap(),
26157 )
26158 .unwrap();
26159 let shared = NodeShared::new(
26160 handle,
26161 "fixture-token".to_owned(),
26162 NodeId::new("node-1").unwrap(),
26163 vec![workspace.clone()],
26164 vec![agent("claude")],
26165 );
26166 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26167 shared
26168 .insert_record(ManagedSessionRecord {
26169 record_id: record_id.clone(),
26170 display_name: "runtime error".to_owned(),
26171 provider: agent("claude"),
26172 mode: SessionMode::Pty,
26173 state: ManagedSessionState::IdentityPending,
26174 workspace_id: workspace.workspace_id,
26175 canonical_root: opaque_windows_path(workspace.canonical_root),
26176 provider_session: None,
26177 active_session: None,
26178 environment_profile: None,
26179 bundle: None,
26180 context_id: None,
26181 context: None,
26182 exported_context: None,
26183 task_binding: None,
26184 created_at_unix_ms: 1,
26185 updated_at_unix_ms: 1,
26186 last_error: None,
26187 })
26188 .unwrap();
26189
26190 shared
26191 .mark_record_error(
26192 &record_id,
26193 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26194 )
26195 .unwrap();
26196 let record = shared.record(&record_id).unwrap();
26197 assert_eq!(record.state, ManagedSessionState::Unavailable);
26198 assert_eq!(
26199 record.last_error.as_deref(),
26200 Some("provider-runtime-failed"),
26201 );
26202 let snapshot = shared.snapshot();
26203 assert_eq!(snapshot.session_records, vec![record]);
26204 }
26205
26206 #[test]
26215 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26216 let catalog = active_registry().unwrap();
26217 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26218 let workspace = WorkspaceConfig::new(
26219 WorkspaceId::new("primary").unwrap(),
26220 std::env::current_dir().unwrap(),
26221 )
26222 .unwrap();
26223 let shared = NodeShared::new(
26224 handle,
26225 "fixture-token".to_owned(),
26226 NodeId::new("node-1").unwrap(),
26227 vec![workspace],
26228 vec![agent("claude")],
26229 );
26230
26231 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26232
26233 shared.record_rejected_command(
26234 CommandId(1),
26235 "agent 'claude' does not support transport Acp".to_owned(),
26236 true,
26237 );
26238 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26240
26241 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26242 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26243 assert!(reason.unsupported_transport);
26244
26245 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26247
26248 shared.record_rejected_command(
26253 CommandId(3),
26254 "backend busy".to_owned(),
26255 false,
26256 );
26257 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26258 assert!(!unclassified.unsupported_transport);
26259 }
26260
26261 #[test]
26262 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26263 let catalog = active_registry().unwrap();
26264 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26265 let workspace = WorkspaceConfig::new(
26266 WorkspaceId::new("primary").unwrap(),
26267 std::env::current_dir().unwrap(),
26268 )
26269 .unwrap();
26270 let shared = NodeShared::new(
26271 handle,
26272 "fixture-token".to_owned(),
26273 NodeId::new("node-identity-policy").unwrap(),
26274 vec![workspace.clone()],
26275 vec![agent("claude")],
26276 );
26277 let address = SessionAddress {
26278 workspace_id: workspace.workspace_id.clone(),
26279 session: SessionKey {
26280 instance_id: AgentInstanceId(90),
26281 generation: SessionGeneration(1),
26282 },
26283 };
26284 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26285 shared
26286 .insert_record(ManagedSessionRecord {
26287 record_id: record_id.clone(),
26288 display_name: "policy denied".to_owned(),
26289 provider: agent("claude"),
26290 mode: SessionMode::Pty,
26291 state: ManagedSessionState::IdentityPending,
26292 workspace_id: workspace.workspace_id,
26293 canonical_root: opaque_windows_path(workspace.canonical_root),
26294 provider_session: None,
26295 active_session: Some(address.clone()),
26296 environment_profile: None,
26297 bundle: None,
26298 context_id: None,
26299 context: None,
26300 exported_context: None,
26301 task_binding: None,
26302 created_at_unix_ms: 1,
26303 updated_at_unix_ms: 1,
26304 last_error: None,
26305 })
26306 .unwrap();
26307 let event = ControlEvent {
26308 sequence: 1,
26309 command_id: None,
26310 instance_id: address.session.instance_id,
26311 generation: address.session.generation,
26312 event: ControlEventKind::ProviderEvent {
26313 sequence: 1,
26314 source: gate4agent_types::ProviderSource {
26315 family: AdapterFamily::PtySemantic,
26316 binding: AdapterBinding::new(
26317 gate4agent_types::AdapterId::new("claude").unwrap(),
26318 "fixture/v1",
26319 gate4agent_types::AdapterVerification::SyntheticFixture,
26320 )
26321 .unwrap(),
26322 },
26323 source_sequence: 1,
26324 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26325 identity: gate4agent_types::ProviderSessionIdentity {
26326 key: gate4agent_types::ProviderSessionKey::SessionId,
26327 id: "provider-session".to_owned(),
26328 transcript_path: None,
26329 },
26330 },
26331 },
26332 };
26333
26334 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26335
26336 let record = shared.record(&record_id).unwrap();
26337 assert!(record.provider_session.is_none());
26338 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26339 assert_eq!(record.active_session.as_ref(), Some(&address));
26340 }
26341
26342 #[test]
26343 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26344 let secondary_root = temporary_workspace_root("identity-scope");
26345 std::fs::create_dir_all(&secondary_root).unwrap();
26346 let catalog = active_registry().unwrap();
26347 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26348 let primary = WorkspaceConfig::new(
26349 WorkspaceId::new("primary").unwrap(),
26350 std::env::current_dir().unwrap(),
26351 )
26352 .unwrap();
26353 let secondary = WorkspaceConfig::new(
26354 WorkspaceId::new("secondary").unwrap(),
26355 &secondary_root,
26356 )
26357 .unwrap();
26358 let shared = NodeShared::new(
26359 handle,
26360 "fixture-token".to_owned(),
26361 NodeId::new("node-1").unwrap(),
26362 vec![primary.clone(), secondary.clone()],
26363 vec![agent("claude")],
26364 );
26365 let identity = gate4agent_types::ProviderSessionIdentity {
26366 key: gate4agent_types::ProviderSessionKey::SessionId,
26367 id: "provider-session".to_owned(),
26368 transcript_path: None,
26369 };
26370 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26371 shared
26372 .insert_record(ManagedSessionRecord {
26373 record_id: dormant_id.clone(),
26374 display_name: "dormant".to_owned(),
26375 provider: agent("claude"),
26376 mode: SessionMode::Pty,
26377 state: ManagedSessionState::Dormant,
26378 workspace_id: primary.workspace_id,
26379 canonical_root: opaque_windows_path(primary.canonical_root),
26380 provider_session: Some(identity.clone()),
26381 active_session: None,
26382 environment_profile: None,
26383 bundle: None,
26384 context_id: None,
26385 context: None,
26386 exported_context: None,
26387 task_binding: None,
26388 created_at_unix_ms: 1,
26389 updated_at_unix_ms: 1,
26390 last_error: None,
26391 })
26392 .unwrap();
26393 let address = SessionAddress {
26394 workspace_id: secondary.workspace_id.clone(),
26395 session: SessionKey {
26396 instance_id: AgentInstanceId(91),
26397 generation: SessionGeneration(1),
26398 },
26399 };
26400 let current_id = SessionRecordId::new("sr-current").unwrap();
26401 shared
26402 .insert_record(ManagedSessionRecord {
26403 record_id: current_id.clone(),
26404 display_name: "current".to_owned(),
26405 provider: agent("claude"),
26406 mode: SessionMode::Pty,
26407 state: ManagedSessionState::IdentityPending,
26408 workspace_id: secondary.workspace_id,
26409 canonical_root: opaque_windows_path(secondary.canonical_root),
26410 provider_session: None,
26411 active_session: Some(address.clone()),
26412 environment_profile: None,
26413 bundle: None,
26414 context_id: None,
26415 context: None,
26416 exported_context: None,
26417 task_binding: None,
26418 created_at_unix_ms: 2,
26419 updated_at_unix_ms: 2,
26420 last_error: None,
26421 })
26422 .unwrap();
26423 shared.bind_managed_session(
26424 &address,
26425 current_id.clone(),
26426 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26427 None,
26428 );
26429 let event = ControlEvent {
26430 sequence: 1,
26431 command_id: None,
26432 instance_id: address.session.instance_id,
26433 generation: address.session.generation,
26434 event: ControlEventKind::ProviderEvent {
26435 sequence: 1,
26436 source: gate4agent_types::ProviderSource {
26437 family: gate4agent_types::AdapterFamily::PtySemantic,
26438 binding: gate4agent_types::AdapterBinding::new(
26439 gate4agent_types::AdapterId::new("claude").unwrap(),
26440 "fixture/v1",
26441 gate4agent_types::AdapterVerification::SyntheticFixture,
26442 )
26443 .unwrap(),
26444 },
26445 source_sequence: 1,
26446 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26447 identity,
26448 },
26449 },
26450 };
26451 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26452
26453 let binding = shared
26454 .session_bindings
26455 .lock()
26456 .unwrap_or_else(|poisoned| poisoned.into_inner())
26457 .get(&address.session.instance_id)
26458 .cloned()
26459 .unwrap();
26460 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26461 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26462 let current = shared.record(¤t_id).unwrap();
26463 assert_eq!(current.state, ManagedSessionState::Unavailable);
26464 assert_eq!(current.active_session.as_ref(), Some(&address));
26465 assert_eq!(
26466 current.last_error.as_deref(),
26467 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26468 );
26469 std::fs::remove_dir_all(secondary_root).unwrap();
26470 }
26471
26472 #[tokio::test]
26473 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26474 let root = temporary_workspace_root("semantic-provider-session");
26475 std::fs::create_dir_all(&root).unwrap();
26476 let state_path = root.join("state-v1.json");
26477 let catalog = active_registry().unwrap();
26478 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26479 let workspace = WorkspaceConfig::new(
26480 WorkspaceId::new("primary").unwrap(),
26481 &root,
26482 )
26483 .unwrap();
26484 let node_id = NodeId::new("node-semantic-identity").unwrap();
26485 let shared = NodeShared::new_with_incarnation(
26486 handle,
26487 "fixture-token".to_owned(),
26488 node_id.clone(),
26489 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26490 vec![workspace.clone()],
26491 vec![agent("claude")],
26492 ProviderRuntimeStatuses::default(),
26493 None,
26494 Vec::new(),
26495 Vec::new(),
26496 SpawnProfileRegistry::default(),
26497 None,
26498 None,
26499 None,
26500 Some(state_path.clone()),
26501 Vec::new(),
26502 Vec::new(),
26503 Vec::new(),
26504 Vec::new(),
26505 None,
26506 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26507 );
26508 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26509 key: gate4agent_types::ProviderSessionKey::SessionId,
26510 id: "provider-session".to_owned(),
26511 transcript_path: None,
26512 };
26513 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26514 shared
26515 .insert_record(ManagedSessionRecord {
26516 record_id: durable_id.clone(),
26517 display_name: "durable".to_owned(),
26518 provider: agent("claude"),
26519 mode: SessionMode::Pty,
26520 state: ManagedSessionState::Dormant,
26521 workspace_id: workspace.workspace_id.clone(),
26522 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26523 provider_session: Some(persisted_identity.clone()),
26524 active_session: None,
26525 environment_profile: None,
26526 bundle: None,
26527 context_id: None,
26528 context: None,
26529 exported_context: None,
26530 task_binding: None,
26531 created_at_unix_ms: 1,
26532 updated_at_unix_ms: 1,
26533 last_error: None,
26534 })
26535 .unwrap();
26536 let address = SessionAddress {
26537 workspace_id: workspace.workspace_id.clone(),
26538 session: SessionKey {
26539 instance_id: AgentInstanceId(92),
26540 generation: SessionGeneration(2),
26541 },
26542 };
26543 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26544 shared
26545 .insert_record(ManagedSessionRecord {
26546 record_id: pending_id.clone(),
26547 display_name: "pending".to_owned(),
26548 provider: agent("claude"),
26549 mode: SessionMode::Pty,
26550 state: ManagedSessionState::IdentityPending,
26551 workspace_id: workspace.workspace_id.clone(),
26552 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26553 provider_session: None,
26554 active_session: Some(address.clone()),
26555 environment_profile: None,
26556 bundle: None,
26557 context_id: None,
26558 context: None,
26559 exported_context: None,
26560 task_binding: None,
26561 created_at_unix_ms: 2,
26562 updated_at_unix_ms: 2,
26563 last_error: None,
26564 })
26565 .unwrap();
26566 shared.bind_managed_session(
26567 &address,
26568 pending_id.clone(),
26569 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26570 None,
26571 );
26572 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26573 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26574 ..persisted_identity
26575 };
26576 let event = ControlEvent {
26577 sequence: 1,
26578 command_id: None,
26579 instance_id: address.session.instance_id,
26580 generation: address.session.generation,
26581 event: ControlEventKind::ProviderEvent {
26582 sequence: 1,
26583 source: gate4agent_types::ProviderSource {
26584 family: gate4agent_types::AdapterFamily::PtySemantic,
26585 binding: gate4agent_types::AdapterBinding::new(
26586 gate4agent_types::AdapterId::new("claude").unwrap(),
26587 "fixture/v1",
26588 gate4agent_types::AdapterVerification::SyntheticFixture,
26589 )
26590 .unwrap(),
26591 },
26592 source_sequence: 1,
26593 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26594 identity: observed_identity.clone(),
26595 },
26596 },
26597 };
26598 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26599
26600 let snapshot = shared.snapshot();
26601 assert_eq!(snapshot.session_records.len(), 1);
26602 let live = &snapshot.session_records[0];
26603 assert_eq!(live.record_id, durable_id);
26604 assert_eq!(live.state, ManagedSessionState::Live);
26605 assert_eq!(live.active_session.as_ref(), Some(&address));
26606 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26607 let binding = shared
26608 .session_bindings
26609 .lock()
26610 .unwrap_or_else(|poisoned| poisoned.into_inner())
26611 .get(&address.session.instance_id)
26612 .cloned()
26613 .unwrap();
26614 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26615
26616 let mut duplicate = live.clone();
26617 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26618 duplicate.display_name = "duplicate".to_owned();
26619 duplicate.state = ManagedSessionState::Dormant;
26620 duplicate.active_session = None;
26621 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26622 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26623 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26624 assert_eq!(shared.snapshot().session_records.len(), 1);
26625
26626 let terminal_size = gate4agent_types::TerminalSize {
26627 rows: 24,
26628 columns: 80,
26629 };
26630 let (first_busy, second_busy) = tokio::join!(
26631 shared.resume_session_record(&durable_id, terminal_size, None),
26632 shared.resume_session_record(&durable_id, terminal_size, None),
26633 );
26634 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26635 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26636 assert_eq!(busy.message, "session-record-busy");
26637 }
26638
26639 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26640 assert_eq!(loaded.records.len(), 1);
26641 assert_eq!(loaded.records[0].record_id, durable_id);
26642 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26643 assert_eq!(
26644 loaded.records[0]
26645 .provider_session
26646 .as_ref()
26647 .unwrap()
26648 .transcript_path,
26649 None,
26650 );
26651 let catalog = active_registry().unwrap();
26652 let (reloaded_handle, _reloaded_runtime) =
26653 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26654 let reloaded = NodeShared::new_with_incarnation(
26655 reloaded_handle,
26656 "fixture-token".to_owned(),
26657 node_id,
26658 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26659 vec![workspace],
26660 vec![agent("claude")],
26661 ProviderRuntimeStatuses::default(),
26662 None,
26663 Vec::new(),
26664 Vec::new(),
26665 SpawnProfileRegistry::default(),
26666 None,
26667 None,
26668 None,
26669 None,
26670 loaded.records,
26671 loaded.managed_worktrees,
26672 loaded.managed_worktree_tombstones,
26673 loaded.materializations,
26674 loaded.warning,
26675 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26676 );
26677 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26678 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26679 std::fs::remove_dir_all(root).unwrap();
26680 }
26681
26682 #[tokio::test]
26683 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26684 let secondary_root = temporary_workspace_root("workspace-restore");
26685 let other_root = temporary_workspace_root("workspace-conflict");
26686 std::fs::create_dir_all(&secondary_root).unwrap();
26687 std::fs::create_dir_all(&other_root).unwrap();
26688 let catalog = active_registry().unwrap();
26689 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26690 let primary = WorkspaceConfig::new(
26691 WorkspaceId::new("primary").unwrap(),
26692 std::env::current_dir().unwrap(),
26693 )
26694 .unwrap();
26695 let secondary = WorkspaceConfig::new(
26696 WorkspaceId::new("secondary").unwrap(),
26697 &secondary_root,
26698 )
26699 .unwrap();
26700 let shared = NodeShared::new(
26701 handle,
26702 "fixture-token".to_owned(),
26703 NodeId::new("node-1").unwrap(),
26704 vec![primary, secondary.clone()],
26705 vec![agent("claude")],
26706 );
26707 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26708 shared
26709 .insert_record(ManagedSessionRecord {
26710 record_id: record_id.clone(),
26711 display_name: "workspace record".to_owned(),
26712 provider: agent("claude"),
26713 mode: SessionMode::Pty,
26714 state: ManagedSessionState::Dormant,
26715 workspace_id: secondary.workspace_id.clone(),
26716 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26717 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26718 key: gate4agent_types::ProviderSessionKey::SessionId,
26719 id: "provider-session".to_owned(),
26720 transcript_path: None,
26721 }),
26722 active_session: None,
26723 environment_profile: None,
26724 bundle: None,
26725 context_id: None,
26726 context: None,
26727 exported_context: None,
26728 task_binding: None,
26729 created_at_unix_ms: 1,
26730 updated_at_unix_ms: 1,
26731 last_error: None,
26732 })
26733 .unwrap();
26734 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26735 let unavailable = shared.record(&record_id).unwrap();
26736 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26737 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26738
26739 let conflict = shared
26740 .register_workspace(
26741 secondary.workspace_id.clone(),
26742 other_root.to_string_lossy().into_owned(),
26743 )
26744 .await
26745 .unwrap_err();
26746 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26747 shared
26748 .register_workspace(
26749 secondary.workspace_id,
26750 secondary_root.to_string_lossy().into_owned(),
26751 )
26752 .await
26753 .unwrap();
26754 let restored = shared.record(&record_id).unwrap();
26755 assert_eq!(restored.state, ManagedSessionState::Dormant);
26756 assert!(restored.last_error.is_none());
26757 std::fs::remove_dir_all(secondary_root).unwrap();
26758 std::fs::remove_dir_all(other_root).unwrap();
26759 }
26760
26761 #[test]
26762 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26763 let error = validate_workspace_request_root(
26764 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26765 )
26766 .unwrap_err();
26767 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26768 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26769 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26770 }
26771
26772 #[test]
26773 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26774 let catalog = active_registry().unwrap();
26775 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26776 let workspace = WorkspaceConfig::new(
26777 WorkspaceId::new("primary").unwrap(),
26778 std::env::current_dir().unwrap(),
26779 )
26780 .unwrap();
26781 let shared = NodeShared::new(
26782 handle,
26783 "fixture-token".to_owned(),
26784 NodeId::new("node-1").unwrap(),
26785 vec![workspace],
26786 vec![agent("claude")],
26787 );
26788 let publishers = 4_u64;
26789 let events_per_publisher = 256_u64;
26790 let start = std::sync::Barrier::new(publishers as usize + 1);
26791 let finished = AtomicU64::new(0);
26792
26793 std::thread::scope(|scope| {
26794 for _ in 0..publishers {
26795 scope.spawn(|| {
26796 start.wait();
26797 for _ in 0..events_per_publisher {
26798 shared.publish(NodeEvent::ControllerChanged { controller: None });
26799 }
26800 finished.fetch_add(1, Ordering::Release);
26801 });
26802 }
26803 start.wait();
26804 while finished.load(Ordering::Acquire) != publishers {
26805 assert_resync_history_is_linearized(shared.resync(0));
26806 std::thread::yield_now();
26807 }
26808 });
26809 let response = shared.resync(0);
26810 assert_resync_history_is_linearized(response.clone());
26811 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26812 unreachable!("resync helper returned another response");
26813 };
26814 assert_eq!(event_sequence, publishers * events_per_publisher);
26815 assert_eq!(events.len() as u64, event_sequence);
26816 }
26817
26818 fn assert_resync_history_is_linearized(response: NodeResponse) {
26819 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26820 unreachable!("resync helper returned another response");
26821 };
26822 assert!(events.windows(2).all(|pair| {
26823 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26824 }));
26825 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26826 }
26827
26828 #[test]
26829 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26830 assert_eq!(
26831 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26832 r"C:\repo\worktree",
26833 );
26834 assert_eq!(
26835 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26836 r"\\server\share\repo",
26837 );
26838 assert!(validate_workspace_root(
26839 &WorkspaceId::new("network").unwrap(),
26840 r"\\server\share\repo",
26841 )
26842 .is_err());
26843 let workspace = WorkspaceConfig::new(
26844 WorkspaceId::new("primary").unwrap(),
26845 std::env::current_dir().unwrap(),
26846 )
26847 .unwrap();
26848 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26849 }
26850
26851 #[test]
26852 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26853 let catalog = active_registry().unwrap();
26854 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26855 let workspace_id = WorkspaceId::new("primary").unwrap();
26856 let workspace = WorkspaceConfig::new(
26857 workspace_id.clone(),
26858 std::env::current_dir().unwrap(),
26859 )
26860 .unwrap();
26861 let shared = NodeShared::new(
26862 handle,
26863 "fixture-token".to_owned(),
26864 NodeId::new("node-1").unwrap(),
26865 vec![workspace],
26866 vec![agent("claude")],
26867 );
26868 let instance_id = AgentInstanceId(17);
26869 let original = SessionAddress {
26870 workspace_id,
26871 session: SessionKey {
26872 instance_id,
26873 generation: SessionGeneration(3),
26874 },
26875 };
26876 shared.bind_session(
26877 instance_id,
26878 original.workspace_id.clone(),
26879 original.session.generation,
26880 );
26881 shared
26882 .arm_resume(
26883 &original,
26884 CommandId(41),
26885 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26886 )
26887 .unwrap();
26888
26889 shared.publish_control(ControlEvent {
26890 sequence: 1,
26891 command_id: None,
26892 instance_id,
26893 generation: SessionGeneration(4),
26894 event: ControlEventKind::Running { process_id: None },
26895 });
26896 assert_eq!(
26897 shared.address_for(instance_id, SessionGeneration(3)),
26898 Some(original.clone()),
26899 );
26900 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26901
26902 shared.publish_control(ControlEvent {
26903 sequence: 2,
26904 command_id: Some(CommandId(42)),
26905 instance_id,
26906 generation: SessionGeneration(3),
26907 event: ControlEventKind::CommandRejected {
26908 message: "unrelated command".to_owned(),
26909 },
26910 });
26911
26912 shared.publish_control(ControlEvent {
26913 sequence: 3,
26914 command_id: None,
26915 instance_id,
26916 generation: SessionGeneration(4),
26917 event: ControlEventKind::ResumeAuthorized {
26918 session: gate4agent_types::ResumeSessionSummary {
26919 key: gate4agent_types::ProviderSessionKey::SessionId,
26920 id: "fixture-session".to_owned(),
26921 },
26922 },
26923 });
26924 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26925 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26926 assert_eq!(rebound.workspace_id, original.workspace_id);
26927 let history = shared
26928 .history
26929 .lock()
26930 .unwrap_or_else(|poisoned| poisoned.into_inner());
26931 assert!(history.events.iter().any(|envelope| matches!(
26932 &envelope.event,
26933 NodeEvent::Control { address, event }
26934 if address == &rebound
26935 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26936 )));
26937 }
26938
26939 #[test]
26940 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26941 for agent in ["claude", "codex", "kimi"] {
26942 assert_eq!(
26943 prompt_framing(&AgentId::new(agent).unwrap()),
26944 PromptFraming::BracketedPaste
26945 );
26946 }
26947 }
26948
26949 fn generous_walk_deadline() -> Instant {
26950 Instant::now() + Duration::from_secs(30)
26951 }
26952
26953 #[test]
26954 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26955 let root = temporary_workspace_root("tree");
26956 std::fs::create_dir_all(root.join("src")).unwrap();
26957 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26958 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26959 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26960 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26961 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26962 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26968 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26969 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26973 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26974
26975 let (entries, truncated, _budget) =
26976 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26977 assert!(!truncated);
26978 assert!(entries.iter().any(|entry| {
26979 entry.relative_path.as_utf8() == Some("src")
26980 && entry.kind == WorkspaceEntryKind::Directory
26981 }));
26982 assert!(entries.iter().any(|entry| {
26983 entry.relative_path.as_utf8() == Some("src/lib.rs")
26984 && entry.kind == WorkspaceEntryKind::File
26985 }));
26986 assert!(entries.iter().any(|entry| {
26987 entry.relative_path.as_utf8() == Some("target-notes")
26988 && entry.kind == WorkspaceEntryKind::Directory
26989 }));
26990 assert!(entries.iter().any(|entry| {
26991 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26992 && entry.kind == WorkspaceEntryKind::File
26993 }));
26994 assert!(entries.iter().all(|entry| {
26995 let path = entry.relative_path.as_utf8().unwrap();
26996 !path.starts_with(".git")
26997 && !(path == "target" || path.starts_with("target/"))
26998 && !(path == "target-a2" || path.starts_with("target-a2/"))
26999 && !path.starts_with("node_modules")
27000 }));
27001
27002 std::fs::remove_dir_all(&root).unwrap();
27003 }
27004
27005 #[test]
27006 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27007 let root = temporary_workspace_root("tree-entry-cap");
27008 std::fs::create_dir_all(&root).unwrap();
27009 for index in 0..20 {
27010 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27011 }
27012
27013 let (entries, truncated, budget) =
27014 collect_workspace_entries(&root, generous_walk_deadline(), 5);
27015 assert!(truncated);
27016 assert!(budget.entry_cap_exceeded);
27017 assert!(!budget.time_budget_exceeded);
27018 assert!(entries.len() <= 5);
27019
27020 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27021 .expect("an entry-cap-truncated walk must report a truncation marker");
27022 assert!(inspection_truncation.walk_entry_cap_exceeded);
27023 assert!(!inspection_truncation.walk_time_budget_exceeded);
27024 assert!(!inspection_truncation.git_time_budget_exceeded);
27025 assert!(inspection_truncation.entries_visited >= 5);
27026
27027 std::fs::remove_dir_all(&root).unwrap();
27028 }
27029
27030 #[test]
27031 fn workspace_walk_within_budget_reports_no_truncation() {
27032 let root = temporary_workspace_root("tree-no-truncation");
27033 std::fs::create_dir_all(&root).unwrap();
27034 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27035
27036 let (_entries, truncated, budget) =
27037 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27038 assert!(!truncated);
27039 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27040
27041 std::fs::remove_dir_all(&root).unwrap();
27042 }
27043
27044 #[test]
27045 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27046 let root = temporary_workspace_root("tree-prefix-siblings");
27047 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27048 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27049 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27050 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27051
27052 let (entries, truncated, _budget) =
27053 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27054 assert!(!truncated);
27055 let paths = entries
27056 .iter()
27057 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27058 .collect::<Vec<_>>();
27059 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27060 assert_eq!(
27061 paths,
27062 vec![
27063 "gate4agent-c2".to_owned(),
27064 "gate4agent-c2-client".to_owned(),
27065 "gate4agent-c2-client/lib.rs".to_owned(),
27066 "gate4agent-c2/src".to_owned(),
27067 "gate4agent-c2/src/lib.rs".to_owned(),
27068 ],
27069 );
27070
27071 std::fs::remove_dir_all(&root).unwrap();
27072 }
27073
27074 #[test]
27075 fn git_parsers_cap_status_and_commit_results() {
27076 let mut status_output = Vec::new();
27077 for index in 0..=GIT_STATUS_MAX_ENTRIES {
27078 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27079 }
27080 let mut snapshot = git_snapshot_for_parser();
27081 parse_git_status(&status_output, &mut snapshot);
27082 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27083 assert!(snapshot.truncated);
27084 assert_eq!(snapshot.status[0].index_status, " ");
27085 assert_eq!(snapshot.status[0].worktree_status, "M");
27086
27087 snapshot.truncated = false;
27088 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27089 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27090 .collect::<String>();
27091 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27092 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27093 assert!(snapshot.truncated);
27094 }
27095
27096 #[test]
27097 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27098 let mut snapshot = git_snapshot_for_parser();
27099 parse_git_status(
27100 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27101 &mut snapshot,
27102 );
27103
27104 assert!(!snapshot.truncated);
27105 assert_eq!(snapshot.status.len(), 3);
27106 assert_eq!(snapshot.status[0].index_status, "R");
27107 assert_eq!(snapshot.status[0].worktree_status, " ");
27108 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27109 assert_eq!(
27110 snapshot.status[0]
27111 .previous_path
27112 .as_ref()
27113 .and_then(RepositoryPath::as_utf8),
27114 Some("src/previous name.rs"),
27115 );
27116 assert_eq!(snapshot.status[1].index_status, " ");
27117 assert_eq!(snapshot.status[1].worktree_status, "C");
27118 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27119 assert_eq!(
27120 snapshot.status[1]
27121 .previous_path
27122 .as_ref()
27123 .and_then(RepositoryPath::as_utf8),
27124 Some("src/original.rs"),
27125 );
27126 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27127 assert!(snapshot.status[2].previous_path.is_none());
27128 }
27129
27130 #[test]
27131 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27132 let mut snapshot = git_snapshot_for_parser();
27133 parse_git_status(
27134 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27135 &mut snapshot,
27136 );
27137
27138 assert!(snapshot.truncated);
27139 assert_eq!(snapshot.status.len(), 1);
27140 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27141 assert!(snapshot.status[0].previous_path.is_none());
27142 }
27143
27144 #[tokio::test]
27145 async fn observer_can_browse_host_directories_without_controller() {
27146 let root = temporary_workspace_root("host-directory-observer");
27147 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27148 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27149 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27150 let workspace_id = WorkspaceId::new("primary").unwrap();
27151 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27152 let catalog = active_registry().unwrap();
27153 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27154 let shared = NodeShared::new(
27155 handle,
27156 "fixture-token".to_owned(),
27157 NodeId::new("node-1").unwrap(),
27158 vec![workspace],
27159 vec![agent("claude")],
27160 );
27161 let request = NodeRequest::BrowseHostDirectories {
27162 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27163 after: None,
27164 };
27165 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27166 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27167 }));
27168 assert!(!request_uses_unnegotiated_capability(
27169 &request,
27170 &baseline_capabilities().unwrap(),
27171 ));
27172 assert!(request_uses_unnegotiated_capability(&request, &[]));
27173
27174 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27175 .await
27176 .unwrap();
27177 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27178 panic!("host directory browse returned another response");
27179 };
27180 assert_eq!(
27181 listing
27182 .entries
27183 .iter()
27184 .map(|entry| entry.display_name.as_str())
27185 .collect::<Vec<_>>(),
27186 vec!["directory-a", "directory-b"],
27187 );
27188 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27189 assert!(!listing.incomplete);
27190 std::fs::remove_dir_all(root).unwrap();
27191 }
27192
27193 #[tokio::test]
27194 async fn observer_can_inspect_only_a_registered_workspace() {
27195 let root = temporary_workspace_root("observer");
27196 std::fs::create_dir_all(root.join("src")).unwrap();
27197 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27198 let workspace_id = WorkspaceId::new("primary").unwrap();
27199 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27200 let catalog = active_registry().unwrap();
27201 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27202 let shared = NodeShared::new(
27203 handle,
27204 "fixture-token".to_owned(),
27205 NodeId::new("node-1").unwrap(),
27206 vec![workspace],
27207 vec![agent("claude")],
27208 );
27209
27210 let response = process_request_inner(
27211 &shared,
27212 77,
27213 ClientRole::Observer,
27214 NodeRequest::InspectWorkspace {
27215 workspace_id: workspace_id.clone(),
27216 },
27217 )
27218 .await
27219 .unwrap();
27220 let NodeResponse::WorkspaceInspected { inspection } = response else {
27221 panic!("workspace inspection returned another response");
27222 };
27223 assert_eq!(inspection.workspace_id, workspace_id);
27224 assert!(inspection.entries.iter().any(|entry| {
27225 entry.relative_path.as_utf8() == Some("src/main.rs")
27226 }));
27227
27228 let unknown = process_request_inner(
27229 &shared,
27230 77,
27231 ClientRole::Observer,
27232 NodeRequest::InspectWorkspace {
27233 workspace_id: WorkspaceId::new("unknown").unwrap(),
27234 },
27235 )
27236 .await
27237 .unwrap_err();
27238 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27239 std::fs::remove_dir_all(&root).unwrap();
27240 }
27241
27242 #[test]
27243 fn context_pack_source_accepts_only_running_or_successful_exit() {
27244 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27245 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27246 exit_code: Some(0),
27247 }));
27248 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27249 exit_code: Some(1),
27250 }));
27251 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27252 exit_code: None,
27253 }));
27254 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27255 message: "provider failed".to_owned(),
27256 }));
27257 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27258 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27259 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27260 }
27261
27262 #[test]
27263 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27264 let git = git_snapshot_for_parser();
27265 let first = ContextPackRepositoryObservation {
27266 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27267 git: git.clone(),
27268 files: Vec::new(),
27269 };
27270 let second = ContextPackRepositoryObservation {
27271 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27272 git,
27273 files: Vec::new(),
27274 };
27275
27276 let error = stable_context_pack_repository(first, second).unwrap_err();
27277
27278 assert_eq!(
27279 error.code,
27280 NodeFailureCode::ContextPackMaterializationFailed,
27281 );
27282 }
27283
27284 #[test]
27285 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27286 let git = git_snapshot_for_parser();
27287 let first = ContextPackRepositoryObservation {
27288 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27289 git: git.clone(),
27290 files: vec![ContextPackRepositoryFileSource::utf8(
27291 "README.md",
27292 "first\n".to_owned(),
27293 6,
27294 )],
27295 };
27296 let second = ContextPackRepositoryObservation {
27297 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27298 git,
27299 files: vec![ContextPackRepositoryFileSource::utf8(
27300 "README.md",
27301 "other\n".to_owned(),
27302 6,
27303 )],
27304 };
27305
27306 let error = stable_context_pack_repository(first, second).unwrap_err();
27307
27308 assert_eq!(
27309 error.code,
27310 NodeFailureCode::ContextPackMaterializationFailed,
27311 );
27312 }
27313
27314 #[tokio::test]
27315 async fn native_session_catalog_zero_active_session_fixture() {
27316 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27317 let history_root = temporary_workspace_root("native-catalog-history");
27318 let outside_root = temporary_workspace_root("native-catalog-outside");
27319 std::fs::create_dir_all(&workspace_root).unwrap();
27320 std::fs::create_dir_all(&history_root).unwrap();
27321 std::fs::create_dir_all(&outside_root).unwrap();
27322 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27323 [
27324 serde_json::json!({
27325 "type": "user",
27326 "sessionId": session_id,
27327 "cwd": cwd.to_str().unwrap(),
27328 "message": { "content": "raw user text must not cross the catalog wire" }
27329 }),
27330 serde_json::json!({
27331 "type": "assistant",
27332 "sessionId": session_id,
27333 "cwd": cwd.to_str().unwrap(),
27334 "message": { "model": "claude-sonnet", "content": "raw answer" }
27335 }),
27336 serde_json::json!({
27337 "type": "ai-title",
27338 "sessionId": session_id,
27339 "aiTitle": title
27340 }),
27341 ]
27342 .into_iter()
27343 .map(|record| record.to_string())
27344 .collect::<Vec<_>>()
27345 .join("\n")
27346 };
27347 std::fs::write(
27348 history_root.join("stable-session.jsonl"),
27349 transcript("stable-session", &workspace_root, "Workspace review"),
27350 )
27351 .unwrap();
27352 std::fs::write(
27353 history_root.join("outside-session.jsonl"),
27354 transcript("outside-session", &outside_root, "Outside review"),
27355 )
27356 .unwrap();
27357 let history = NativeHistoryConfig::new(vec![
27358 NativeHistoryRoot::new(
27359 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27360 HistorySourceLayout::SingleNdjson,
27361 history_root.clone(),
27362 )
27363 .unwrap(),
27364 ])
27365 .unwrap();
27366 let catalog = active_registry().unwrap();
27367 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27368 assert!(handle.snapshot().sessions.is_empty());
27369 let workspace_id = WorkspaceId::new("primary").unwrap();
27370 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27371 let mut shared = NodeShared::new(
27372 handle,
27373 "fixture-token".to_owned(),
27374 NodeId::new("node-native-catalog").unwrap(),
27375 vec![workspace],
27376 vec![agent("claude")],
27377 );
27378 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27379 NativeSessionCatalogAuthority::new(history),
27380 )));
27381 let route = NativeSessionCatalogRoute::workspace(
27382 workspace_id.clone(),
27383 agent("claude"),
27384 );
27385 let request = NodeRequest::CatalogNativeSessions {
27386 route: route.clone(),
27387 limit: 2,
27388 };
27389 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27390 .await
27391 .unwrap_err();
27392 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27393 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27394 .await
27395 .unwrap();
27396 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27397 panic!("native catalog returned a different response");
27398 };
27399 let summary = summary.expect("node must emit native session catalog summary metadata");
27400 assert_eq!(entries.len(), 1);
27401 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27402 assert_eq!(entries[0].external_group, None);
27403 assert_eq!(entries[0].record_id, None);
27404 assert_eq!(entries[0].title, None);
27405 assert_eq!(entries[0].model, None);
27406 assert_eq!(entries[0].message_count, 0);
27407 let encoded_entries = serde_json::to_string(&entries).unwrap();
27408 assert!(!encoded_entries.contains("stable-session"));
27409 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27410 let page_request = NodeRequest::PageNativeSessions {
27411 route: route.clone(),
27412 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27413 catalog_revision: summary.catalog_revision,
27414 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27415 after_selection_id: None,
27416 limit: 2,
27417 };
27418 let observer = process_request_inner(
27419 &shared,
27420 7,
27421 ClientRole::Observer,
27422 page_request.clone(),
27423 )
27424 .await
27425 .unwrap_err();
27426 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27427 let page_response = process_request_inner(
27428 &shared,
27429 7,
27430 ClientRole::Operator,
27431 page_request.clone(),
27432 )
27433 .await
27434 .unwrap();
27435 assert!(matches!(
27436 page_response,
27437 NodeResponse::NativeSessionsPaged { page, .. }
27438 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27439 ));
27440 let stale = process_request_inner(
27441 &shared,
27442 7,
27443 ClientRole::Operator,
27444 NodeRequest::PageNativeSessions {
27445 route: route.clone(),
27446 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27447 catalog_revision: summary.catalog_revision.wrapping_add(1),
27448 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27449 after_selection_id: None,
27450 limit: 2,
27451 },
27452 )
27453 .await
27454 .unwrap_err();
27455 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27456 let selection = NativeSessionSelection {
27457 route: route.clone(),
27458 catalog_revision: summary.catalog_revision,
27459 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27460 selection_id: entries[0].selection_id.clone(),
27461 };
27462 let preview_response = process_request_inner(
27463 &shared,
27464 7,
27465 ClientRole::Operator,
27466 NodeRequest::PreviewNativeSession {
27467 selection: selection.clone(),
27468 message_limit: 1,
27469 },
27470 )
27471 .await
27472 .unwrap();
27473 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27474 panic!("native preview returned a different response");
27475 };
27476 assert_eq!(preview.messages.len(), 1);
27477 assert!(preview.message_count_exact);
27478 assert_eq!(preview.messages[0].text, "raw answer");
27479 assert!(preview.truncated);
27480
27481 let index_request = NodeRequest::IndexNativeSession {
27482 selection: selection.clone(),
27483 display_name: "Saved Claude".to_owned(),
27484 };
27485 let observer = process_request_inner(
27486 &shared,
27487 7,
27488 ClientRole::Observer,
27489 index_request.clone(),
27490 )
27491 .await
27492 .unwrap_err();
27493 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27494 let without_controller = process_request_inner(
27495 &shared,
27496 7,
27497 ClientRole::Operator,
27498 index_request.clone(),
27499 )
27500 .await
27501 .unwrap_err();
27502 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27503 let acquired = process_request_inner(
27504 &shared,
27505 7,
27506 ClientRole::Operator,
27507 NodeRequest::AcquireController { lease_ms: 5_000 },
27508 )
27509 .await
27510 .unwrap();
27511 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27512 let indexed = process_request_inner(
27513 &shared,
27514 7,
27515 ClientRole::Operator,
27516 index_request,
27517 )
27518 .await
27519 .unwrap();
27520 assert!(indexed.native_session_index_contract_is_valid());
27521 let NodeResponse::NativeSessionIndexed {
27522 selection: echoed_selection,
27523 record,
27524 } = indexed else {
27525 panic!("native session index returned a different response");
27526 };
27527 assert_eq!(echoed_selection, selection);
27528 let recataloged = process_request_inner(
27529 &shared,
27530 7,
27531 ClientRole::Operator,
27532 NodeRequest::CatalogNativeSessions {
27533 route,
27534 limit: 2,
27535 },
27536 )
27537 .await
27538 .unwrap();
27539 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27540 panic!("native catalog returned a different response after indexing");
27541 };
27542 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27543
27544 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27545 let external_catalog = process_request_inner(
27546 &shared,
27547 7,
27548 ClientRole::Operator,
27549 NodeRequest::CatalogNativeSessions {
27550 route: unregistered_route.clone(),
27551 limit: 2,
27552 },
27553 )
27554 .await
27555 .unwrap();
27556 let external_json = serde_json::to_string(&external_catalog).unwrap();
27557 assert!(!external_json.contains("outside-session"));
27558 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27559 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27560 assert!(!external_json.contains("\"session_id\""));
27561 assert!(!external_json.contains("\"cwd\""));
27562 assert!(!external_json.contains("\"path\""));
27563 let NodeResponse::NativeSessionsCataloged {
27564 route: echoed_route,
27565 entries: external_entries,
27566 summary: Some(external_summary),
27567 } = external_catalog
27568 else {
27569 panic!("unregistered native catalog returned a different response");
27570 };
27571 assert_eq!(echoed_route, unregistered_route);
27572 assert_eq!(external_entries.len(), 1);
27573 assert_eq!(external_entries[0].record_id, None);
27574 let external_group = external_entries[0]
27575 .external_group
27576 .as_ref()
27577 .expect("unregistered session must have an external group");
27578 assert_eq!(
27579 external_group.kind,
27580 gate4agent_types::NativeSessionExternalGroupKind::Project,
27581 );
27582 assert!(external_group.group_id.starts_with("external-"));
27583 assert_eq!(
27584 external_group.display_name,
27585 outside_root.file_name().unwrap().to_string_lossy(),
27586 );
27587 let external_selection = NativeSessionSelection {
27588 route: unregistered_route,
27589 catalog_revision: external_summary.catalog_revision,
27590 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27591 selection_id: external_entries[0].selection_id.clone(),
27592 };
27593 let external_preview = process_request_inner(
27594 &shared,
27595 7,
27596 ClientRole::Operator,
27597 NodeRequest::PreviewNativeSession {
27598 selection: external_selection.clone(),
27599 message_limit: 1,
27600 },
27601 )
27602 .await
27603 .unwrap();
27604 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27605 assert!(!external_preview_json.contains("outside-session"));
27606 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27607 assert!(!external_preview_json.contains("\"session_id\""));
27608 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27609 panic!("unregistered native preview returned a different response");
27610 };
27611 assert_eq!(preview.messages.len(), 1);
27612 let external_index = process_request_inner(
27613 &shared,
27614 7,
27615 ClientRole::Operator,
27616 NodeRequest::IndexNativeSession {
27617 selection: external_selection,
27618 display_name: "External Claude".to_owned(),
27619 },
27620 )
27621 .await
27622 .unwrap_err();
27623 assert_eq!(
27624 external_index.code,
27625 NodeFailureCode::WorkspaceRegistrationRequired,
27626 );
27627 let record_response = process_request_inner(
27628 &shared,
27629 7,
27630 ClientRole::Operator,
27631 NodeRequest::PreviewSessionRecord {
27632 record_id: record.record_id.clone(),
27633 message_limit: 2,
27634 },
27635 )
27636 .await
27637 .unwrap();
27638 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27639 panic!("record preview returned a different response");
27640 };
27641 assert_eq!(record_id, record.record_id);
27642 assert_eq!(preview.messages.len(), 2);
27643 let encoded = serde_json::to_string(&preview).unwrap();
27644 assert!(!encoded.contains("stable-session"));
27645 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27646 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27647 panic!("record preview history summary returned a different response");
27648 };
27649 let summaries = events
27650 .iter()
27651 .filter_map(|envelope| match &envelope.event {
27652 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27653 if record_id == &record.record_id =>
27654 {
27655 Some(*summary)
27656 }
27657 _ => None,
27658 })
27659 .collect::<Vec<_>>();
27660 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27661 let summary_json = serde_json::to_string(&summaries).unwrap();
27662 for private in [
27663 "stable-session",
27664 "raw user text must not cross the catalog wire",
27665 "raw answer",
27666 "Workspace review",
27667 "claude-sonnet",
27668 ] {
27669 assert!(!summary_json.contains(private), "{private}");
27670 }
27671 let identity = record.provider_session.clone().unwrap();
27672 assert_eq!(
27673 shared.revalidate_session_record_preview(&record, &identity),
27674 Ok(())
27675 );
27676 shared.remove_record_memory(&record.record_id);
27677 assert_eq!(
27678 shared
27679 .revalidate_session_record_preview(&record, &identity)
27680 .unwrap_err()
27681 .code,
27682 NodeFailureCode::UnknownSessionRecord
27683 );
27684 let released = process_request_inner(
27685 &shared,
27686 7,
27687 ClientRole::Operator,
27688 NodeRequest::ReleaseController,
27689 )
27690 .await
27691 .unwrap();
27692 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27693 assert!(shared.controller_state().is_none());
27694 std::fs::remove_dir_all(workspace_root).unwrap();
27695 std::fs::remove_dir_all(history_root).unwrap();
27696 std::fs::remove_dir_all(outside_root).unwrap();
27697 }
27698
27699 #[tokio::test]
27700 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27701 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27702 std::fs::create_dir_all(&history_root).unwrap();
27703 let history = NativeHistoryConfig::new(vec![
27704 NativeHistoryRoot::new(
27705 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27706 HistorySourceLayout::SingleNdjson,
27707 history_root.clone(),
27708 )
27709 .unwrap(),
27710 ])
27711 .unwrap();
27712 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27713 let held = catalog.lock().unwrap();
27714 let result = timeout(
27715 Duration::from_secs(1),
27716 run_native_session_catalog_operation(
27717 Arc::clone(&catalog),
27718 "native session catalog",
27719 |_| Ok::<(), std::convert::Infallible>(()),
27720 ),
27721 )
27722 .await
27723 .expect("concurrent native catalog request must not wait for the operation deadline")
27724 .unwrap_err();
27725 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27726 drop(held);
27727 std::fs::remove_dir_all(history_root).unwrap();
27728 }
27729
27730 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27731 let unique = std::time::SystemTime::now()
27732 .duration_since(std::time::UNIX_EPOCH)
27733 .unwrap()
27734 .as_nanos();
27735 std::env::temp_dir().join(format!(
27736 "gate4agent-node-{label}-{}-{unique}",
27737 std::process::id(),
27738 ))
27739 }
27740
27741 fn git_snapshot_for_parser() -> GitSnapshot {
27742 GitSnapshot {
27743 is_repository: true,
27744 branch: Some("main".to_owned()),
27745 status: Vec::new(),
27746 recent_commits: Vec::new(),
27747 worktrees: Vec::new(),
27748 managed_worktree: None,
27749 truncated: false,
27750 diagnostic: None,
27751 }
27752 }
27753
27754 #[tokio::test]
27755 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27756 let root = temporary_workspace_root("managed-git-scope");
27757 std::fs::create_dir_all(&root).unwrap();
27758 run_test_git(&root, &["init"]);
27759 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27760 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27761 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27762 run_test_git(&root, &["add", "seed.txt"]);
27763 run_test_git(&root, &["commit", "-m", "seed"]);
27764 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27765
27766 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27767 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27768 let catalog = active_registry().unwrap();
27769 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27770 let shared = NodeShared::new(
27771 handle,
27772 "fixture-token".to_owned(),
27773 NodeId::new("node-1").unwrap(),
27774 vec![workspace],
27775 vec![agent("claude")],
27776 );
27777 let mut lease = ManagedWorktreeLeaseRecord {
27778 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27779 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27780 workspace_id: workspace_id.clone(),
27781 profile_id: WorktreeProfileId::new("default").unwrap(),
27782 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27783 target_root: root.to_string_lossy().into_owned(),
27784 branch: "gate4agent/a".to_owned(),
27785 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27786 expected_head: None,
27787 retention: ManagedWorktreeRetention::Retain,
27788 state: ManagedWorktreeLeaseState::InUse,
27789 session_holders: vec![ManagedWorktreeSessionHolder {
27790 incarnation_id: shared.incarnation_id,
27791 instance_id: AgentInstanceId(7),
27792 generation: SessionGeneration(1),
27793 }],
27794 record_holders: Vec::new(),
27795 cleanup_failure: None,
27796 created_at_unix_ms: 1,
27797 updated_at_unix_ms: 1,
27798 };
27799 *shared
27800 .managed_worktrees
27801 .lock()
27802 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27803 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27804
27805 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27806 let scope = inspection.git.managed_worktree.unwrap();
27807 assert_eq!(scope.lease_id, lease.lease_id);
27808 assert_eq!(scope.branch, "gate4agent/a");
27809 assert_eq!(scope.active_session_count, 1);
27810
27811 lease.branch = "gate4agent/b".to_owned();
27812 *shared
27813 .managed_worktrees
27814 .lock()
27815 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27816 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27817 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27818 assert!(mismatch.git.managed_worktree.is_none());
27819 std::fs::remove_dir_all(root).unwrap();
27820 }
27821
27822 #[tokio::test]
27823 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27824 let root = temporary_workspace_root("workspace-file-write-controller");
27825 std::fs::create_dir_all(root.join("src")).unwrap();
27826 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27827 let workspace_id = WorkspaceId::new("primary").unwrap();
27828 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27829 let catalog = active_registry().unwrap();
27830 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27831 let shared = NodeShared::new(
27832 handle,
27833 "fixture-token".to_owned(),
27834 NodeId::new("node-1").unwrap(),
27835 vec![workspace],
27836 vec![agent("claude")],
27837 );
27838 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27839 let expected_revision = workspace_file_revision(b"old\n");
27840 let request = NodeRequest::WriteWorkspaceFile {
27841 workspace_id: workspace_id.clone(),
27842 path: path.clone(),
27843 expected_revision: expected_revision.clone(),
27844 text: "new\n".to_owned(),
27845 };
27846 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27847 .await
27848 .unwrap_err();
27849 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27850 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27851 .await
27852 .unwrap_err();
27853 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27854 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27855 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27856 let NodeResponse::WorkspaceFileWritten { file } = response else {
27857 panic!("workspace file write returned another response");
27858 };
27859 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27860 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27861 let stale = process_request_inner(
27862 &shared,
27863 77,
27864 ClientRole::Operator,
27865 NodeRequest::WriteWorkspaceFile {
27866 workspace_id,
27867 path,
27868 expected_revision,
27869 text: "lost update\n".to_owned(),
27870 },
27871 )
27872 .await
27873 .unwrap_err();
27874 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27875 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27876 std::fs::remove_dir_all(root).unwrap();
27877 }
27878
27879 #[tokio::test]
27880 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27881 let root = temporary_workspace_root("workspace-entry-create-controller");
27882 std::fs::create_dir_all(root.join("src")).unwrap();
27883 let workspace_id = WorkspaceId::new("primary").unwrap();
27884 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27885 let catalog = active_registry().unwrap();
27886 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27887 let shared = NodeShared::new(
27888 handle,
27889 "fixture-token".to_owned(),
27890 NodeId::new("node-1").unwrap(),
27891 vec![workspace],
27892 vec![agent("claude")],
27893 );
27894 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27895 let request = NodeRequest::CreateWorkspaceFile {
27896 workspace_id: workspace_id.clone(),
27897 path: path.clone(),
27898 };
27899 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27900 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27901 }));
27902 assert!(!request_uses_unnegotiated_capability(
27903 &request,
27904 &baseline_capabilities().unwrap(),
27905 ));
27906 assert!(request_uses_unnegotiated_capability(&request, &[]));
27907 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27908 .await
27909 .unwrap_err();
27910 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27911 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27912 .await
27913 .unwrap_err();
27914 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27915 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27916
27917 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27918 .await
27919 .unwrap();
27920 let NodeResponse::WorkspaceFileCreated { file } = response else {
27921 panic!("workspace file create returned another response");
27922 };
27923 assert_eq!(file.workspace_id, workspace_id);
27924 assert_eq!(file.path, path);
27925 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27926 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27927
27928 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27929 .await
27930 .unwrap_err();
27931 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27932
27933 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27934 let response = process_request_inner(
27935 &shared,
27936 77,
27937 ClientRole::Operator,
27938 NodeRequest::CreateWorkspaceDirectory {
27939 workspace_id: workspace_id.clone(),
27940 path: directory_path.clone(),
27941 },
27942 )
27943 .await
27944 .unwrap();
27945 assert_eq!(
27946 response,
27947 NodeResponse::WorkspaceDirectoryCreated {
27948 workspace_id: workspace_id.clone(),
27949 entry: WorkspaceEntry {
27950 relative_path: directory_path,
27951 kind: WorkspaceEntryKind::Directory,
27952 },
27953 },
27954 );
27955 assert!(root.join("src/nested").is_dir());
27956
27957 let missing_parent = process_request_inner(
27958 &shared,
27959 77,
27960 ClientRole::Operator,
27961 NodeRequest::CreateWorkspaceFile {
27962 workspace_id: workspace_id.clone(),
27963 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27964 },
27965 )
27966 .await
27967 .unwrap_err();
27968 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27969 let non_directory_parent = process_request_inner(
27970 &shared,
27971 77,
27972 ClientRole::Operator,
27973 NodeRequest::CreateWorkspaceFile {
27974 workspace_id,
27975 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27976 },
27977 )
27978 .await
27979 .unwrap_err();
27980 assert_eq!(
27981 non_directory_parent.code,
27982 NodeFailureCode::RepositoryParentNotDirectory,
27983 );
27984 std::fs::remove_dir_all(root).unwrap();
27985 }
27986
27987 #[tokio::test]
27988 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27989 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27990 std::fs::create_dir_all(root.join("src")).unwrap();
27991 #[cfg(windows)]
27992 let root = std::fs::canonicalize(root).unwrap();
27993 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27994 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27995 let barrier = Arc::new(std::sync::Barrier::new(2));
27996 let done = Arc::new(AtomicBool::new(false));
27997 let task_commit_state = Arc::clone(&commit_state);
27998 let task_barrier = Arc::clone(&barrier);
27999 let task_done = Arc::clone(&done);
28000 let task_root = root.clone();
28001 let task = tokio::task::spawn_blocking(move || {
28002 task_barrier.wait();
28003 let result = create_workspace_file_on_disk(
28004 &task_root,
28005 &path,
28006 &task_commit_state,
28007 );
28008 task_done.store(true, Ordering::Release);
28009 result
28010 });
28011
28012 let error = settle_workspace_entry_create(
28013 task,
28014 Arc::clone(&commit_state),
28015 Duration::from_millis(10),
28016 "fixture deadline",
28017 "fixture task failed",
28018 )
28019 .await
28020 .unwrap_err();
28021 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28022 assert_eq!(
28023 commit_state.load(Ordering::Acquire),
28024 WORKSPACE_ENTRY_CREATE_CANCELED,
28025 );
28026
28027 barrier.wait();
28028 timeout(Duration::from_secs(1), async {
28029 while !done.load(Ordering::Acquire) {
28030 tokio::task::yield_now().await;
28031 }
28032 })
28033 .await
28034 .expect("canceled blocking create did not settle");
28035 assert!(!root.join("src/late.rs").exists());
28036 std::fs::remove_dir_all(root).unwrap();
28037 }
28038}
28039
28040#[cfg(test)]
28041mod provisional_spawn_tests {
28042 use super::*;
28043 use std::sync::Arc;
28044
28045 fn shared() -> NodeShared {
28046 let catalog = active_registry().unwrap();
28047 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28048 let workspace = WorkspaceConfig::new(
28049 WorkspaceId::new("primary").unwrap(),
28050 std::env::current_dir().unwrap(),
28051 )
28052 .unwrap()
28053 .with_worktree_service_mode(WorktreeServiceMode::Manual);
28054 NodeShared::new(
28055 handle,
28056 "fixture-token".to_owned(),
28057 NodeId::new("node-provisional-test").unwrap(),
28058 vec![workspace],
28059 vec![AgentId::new("claude").unwrap()],
28060 )
28061 }
28062
28063 #[test]
28066 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28067 let shared = shared();
28068 let ws = WorkspaceId::new("primary").unwrap();
28069 let mut ids = Vec::with_capacity(50);
28070 for i in 1u64..=50 {
28071 let id = AgentInstanceId(i);
28072 ids.push(id);
28073 let address = SessionAddress {
28074 workspace_id: ws.clone(),
28075 session: SessionKey {
28076 instance_id: id,
28077 generation: SessionGeneration(1),
28078 },
28079 };
28080 shared.mark_spawn_provisional(address);
28081 }
28082 assert_eq!(
28083 shared
28084 .provisional_spawns
28085 .lock()
28086 .unwrap_or_else(|p| p.into_inner())
28087 .len(),
28088 50
28089 );
28090 for id in ids.iter().step_by(2) {
28091 shared.sessionize_spawn(*id);
28092 }
28093 assert_eq!(
28094 shared
28095 .provisional_spawns
28096 .lock()
28097 .unwrap_or_else(|p| p.into_inner())
28098 .len(),
28099 25
28100 );
28101 assert!(shared.take_due_provisional_spawns().is_empty());
28102 {
28103 let mut map = shared
28104 .provisional_spawns
28105 .lock()
28106 .unwrap_or_else(|p| p.into_inner());
28107 let past = Instant::now() - Duration::from_secs(30);
28108 for (_, (_, at)) in map.iter_mut() {
28109 *at = past;
28110 }
28111 }
28112 let due = shared.take_due_provisional_spawns();
28113 assert_eq!(due.len(), 25);
28114 let due_ids: std::collections::BTreeSet<_> =
28115 due.iter().map(|a| a.session.instance_id.0).collect();
28116 let expected: std::collections::BTreeSet<_> =
28117 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28118 assert_eq!(due_ids, expected);
28119 assert!(shared
28120 .provisional_spawns
28121 .lock()
28122 .unwrap_or_else(|p| p.into_inner())
28123 .is_empty());
28124 }
28125
28126 #[test]
28129 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28130 let shared = Arc::new(shared());
28131 let ws = WorkspaceId::new("primary").unwrap();
28132 let mut handles = Vec::new();
28133 for i in 1u64..=20 {
28134 let shared = Arc::clone(&shared);
28135 let ws = ws.clone();
28136 handles.push(std::thread::spawn(move || {
28137 let id = AgentInstanceId(i);
28138 let address = SessionAddress {
28139 workspace_id: ws,
28140 session: SessionKey {
28141 instance_id: id,
28142 generation: SessionGeneration(1),
28143 },
28144 };
28145 shared.mark_spawn_provisional(address);
28146 id
28147 }));
28148 }
28149 let mut ids = Vec::new();
28150 for h in handles {
28151 ids.push(h.join().unwrap());
28152 }
28153 ids.sort_by_key(|id| id.0);
28154 assert_eq!(ids.len(), 20);
28155 ids.dedup();
28156 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28157 assert_eq!(
28158 shared
28159 .provisional_spawns
28160 .lock()
28161 .unwrap_or_else(|p| p.into_inner())
28162 .len(),
28163 20
28164 );
28165 shared.sessionize_spawn(AgentInstanceId(7));
28166 let left: Vec<_> = shared
28167 .provisional_spawns
28168 .lock()
28169 .unwrap_or_else(|p| p.into_inner())
28170 .keys()
28171 .map(|id| id.0)
28172 .collect();
28173 assert!(!left.contains(&7));
28174 assert_eq!(left.len(), 19);
28175 }
28176}
28177
28178
28179#[cfg(unix)]
28180extern "C" {
28181 #[link_name = "geteuid"]
28182 fn libc_geteuid() -> u32;
28183}
28184
28185#[cfg(test)]
28186mod call_home_link_tests {
28187 use super::*;
28188
28189 #[test]
28190 fn call_home_backoff_is_capped_and_monotonic() {
28191 let mut last = Duration::ZERO;
28192 for attempt in 1..20 {
28193 let next = call_home_backoff(attempt);
28194 assert!(next >= last);
28195 assert!(next <= Duration::from_secs(8));
28196 last = next;
28197 }
28198 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28199 }
28200
28201 #[tokio::test]
28202 async fn call_home_socket_gets_keepalive() {
28203 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28204 let addr = listener.local_addr().unwrap();
28205 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28206 let client = client.unwrap();
28207 enable_call_home_keepalive(&client).unwrap();
28208 let sock = socket2::SockRef::from(&client);
28209 assert!(sock.keepalive().unwrap());
28210 #[cfg(any(target_os = "linux", target_os = "macos"))]
28211 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28212 }
28213}