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 #[cfg(unix)]
3222 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3223 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3224 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3225 managed_spawn_replays:
3226 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3227 controller: Mutex<Option<ControllerLease>>,
3228 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3231 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3233 history: Mutex<NodeEventHistory>,
3234 event_tx: broadcast::Sender<NodeEventEnvelope>,
3235 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3236 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3244 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3253 terminal_frame_watermarks:
3254 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3255 pty_input_wake: tokio::sync::Notify,
3257 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3260 next_connection_id: AtomicU64,
3261 next_instance_id: AtomicU64,
3262 next_command_id: AtomicU64,
3263 shutdown: AtomicBool,
3264 shutdown_notify: Notify,
3265 preauth_slots: Arc<Semaphore>,
3266 authenticated_slots: Arc<Semaphore>,
3267 inspection_slots: Arc<Semaphore>,
3268 mutation_gate: AsyncMutex<()>,
3269 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3270 session_records: Mutex<SessionRecords>,
3271 state_path: Option<PathBuf>,
3272 persistence_error: RwLock<Option<String>>,
3273 state_transaction: Mutex<()>,
3274 input_settle_timeout_ms: u64,
3275 drive_loop_profile: Mutex<DriveLoopProfiler>,
3279 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3285 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3297 native_session_gauge: AtomicUsize,
3304 connection_loop_iterations: AtomicU64,
3315 connection_events_sent: AtomicU64,
3316 rejected_commands_total: AtomicU64,
3325 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3345 drive_loop_iterations_total: AtomicU64,
3346 drive_loop_iterations_idle: AtomicU64,
3347 session_record_retention: SessionRecordRetentionConfig,
3351 retired_records_total: AtomicU64,
3357 #[cfg(feature = "fixture")]
3358 fixture_semantic_hook_policy: bool,
3359 #[cfg(feature = "fixture")]
3360 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3361}
3362
3363impl Drop for NodeShared {
3364 fn drop(&mut self) {
3365 SessionLifecycleHook::on_shutdown(self);
3366 }
3367}
3368
3369#[derive(Clone, Debug, Eq, PartialEq)]
3370struct SessionBinding {
3371 workspace_id: WorkspaceId,
3372 generation: SessionGeneration,
3373 runtime_policy: ProviderRuntimePolicy,
3374 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3375 record_id: Option<SessionRecordId>,
3376 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3377 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3378 bundle: Option<ResolvedBundleReceipt>,
3379 context: Option<ResolvedContextPackReceipt>,
3380 materialization_id: Option<MaterializationId>,
3381}
3382
3383#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3384enum SpawnRecordPolicy {
3385 ProviderIdentityOnly,
3386 Always,
3387}
3388
3389struct NativeEnvironmentSelectionGuard {
3390 control: Option<NativeLaunchProfileControl>,
3391 instance_id: AgentInstanceId,
3392}
3393
3394struct NativeInstanceOverlayGuard {
3395 control: Option<NativeLaunchProfileControl>,
3396 instance_id: AgentInstanceId,
3397}
3398
3399struct NativeHarnessMcpOverlayGuard {
3400 control: NativeLaunchProfileControl,
3401 instance_id: AgentInstanceId,
3402}
3403
3404impl NativeHarnessMcpOverlayGuard {
3405 fn retain(self) { std::mem::forget(self); }
3406}
3407
3408impl Drop for NativeHarnessMcpOverlayGuard {
3409 fn drop(&mut self) {
3410 self.control
3411 .clear_native_mcp_server_launch_overlay(self.instance_id);
3412 }
3413}
3414
3415impl NativeInstanceOverlayGuard {
3416 fn retain(mut self) {
3417 self.control = None;
3418 }
3419}
3420
3421impl Drop for NativeInstanceOverlayGuard {
3422 fn drop(&mut self) {
3423 if let Some(control) = self.control.as_ref() {
3424 control.clear_native_instance_launch_overlay(self.instance_id);
3425 }
3426 }
3427}
3428
3429enum PreparedNativeLaunchOverlay {
3430 Environment(NativeLaunchEnvironmentOverlay),
3431 Instance(NativeInstanceLaunchOverlay),
3432}
3433
3434impl NativeEnvironmentSelectionGuard {
3435 fn retain(mut self) {
3436 self.control = None;
3437 }
3438}
3439
3440impl Drop for NativeEnvironmentSelectionGuard {
3441 fn drop(&mut self) {
3442 if let Some(control) = self.control.as_ref() {
3443 control.clear_native_launch_profile_selection(self.instance_id);
3444 }
3445 }
3446}
3447
3448struct SessionMaterializationGuard<'a> {
3449 shared: &'a NodeShared,
3450 id: Option<MaterializationId>,
3451 provider_home: Option<PathBuf>,
3454}
3455
3456impl SessionMaterializationGuard<'_> {
3457 fn id(&self) -> Option<&MaterializationId> {
3458 self.id.as_ref()
3459 }
3460
3461 fn provider_home(&self) -> Option<&Path> {
3462 self.provider_home.as_deref()
3463 }
3464
3465 fn retain(mut self) {
3466 self.id = None;
3467 }
3468}
3469
3470impl Drop for SessionMaterializationGuard<'_> {
3471 fn drop(&mut self) {
3472 if let Some(id) = self.id.take() {
3473 let _ = self.shared.cleanup_materialization(&id);
3474 }
3475 }
3476}
3477
3478
3479trait SessionLifecycleHook {
3482 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3484 fn on_session_ended(&self, address: &SessionAddress);
3486 fn on_shutdown(&self);
3488 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3490 fn cleanup_session_materialization(
3492 &self,
3493 address: &SessionAddress,
3494 binding: &SessionBinding,
3495 ) -> Result<(), NodeFailure>;
3496}
3497
3498impl SessionLifecycleHook for NodeShared {
3499 fn on_session_ended(&self, address: &SessionAddress) {
3500 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3501 registry.revoke_session(address);
3502 }
3503 if let Some(control) = self.native_launch_profile_control.as_ref() {
3504 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3505 }
3506 }
3507
3508 fn on_shutdown(&self) {
3509 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3510 let instances = registry.shutdown();
3511 if let Some(control) = self.native_launch_profile_control.as_ref() {
3512 for instance_id in instances {
3513 control.clear_native_mcp_server_launch_overlay(instance_id);
3514 }
3515 }
3516 }
3517 }
3518
3519 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3520 if let Some(control) = self.native_launch_profile_control.as_ref() {
3521 control.clear_native_mcp_server_launch_overlay(instance_id);
3522 }
3523 }
3524
3525 fn cleanup_session_materialization(
3526 &self,
3527 address: &SessionAddress,
3528 binding: &SessionBinding,
3529 ) -> Result<(), NodeFailure> {
3530 self.cleanup_session_owned_materialization(address, binding)
3531 }
3532
3533 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3534 #[cfg(feature = "dig2-station-probe")]
3537 {
3538 crate::dig2_station_lease::release_lease_for_holder(
3539 &self.browser_station_leases,
3540 address.session.instance_id,
3541 );
3542 }
3543 if let Some(control) = self.native_launch_profile_control.as_ref() {
3544 control.clear_native_mcp_server_launch_overlay(
3545 address.session.instance_id,
3546 );
3547 }
3548 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3549 registry.revoke_session(address);
3550 }
3551 if removed
3552 .is_some_and(|binding| {
3553 binding.environment_profile.is_some()
3554 || binding.bundle.is_some()
3555 || binding.context.is_some()
3556 })
3557 {
3558 if let Some(control) = self.native_launch_profile_control.as_ref() {
3559 control.clear_native_launch_profile_selection(
3560 address.session.instance_id,
3561 );
3562 control.clear_native_instance_launch_overlay(
3563 address.session.instance_id,
3564 );
3565 }
3566 }
3567 }
3568}
3569
3570struct SpawnPrepareRequest<'r> {
3571 instance_id: AgentInstanceId,
3572 address: &'r SessionAddress,
3573 provider: &'r AgentId,
3574 mode: SessionMode,
3575 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3576 bundle: Option<&'r ResolvedBundleReceipt>,
3577 context: Option<&'r ResolvedContextPackReceipt>,
3578 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3579 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3580 approval_level: ApprovalLevel,
3581 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3582 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3583}
3584
3585struct PreparedSpawn<'a> {
3589 instance_overlay: Option<NativeInstanceOverlayGuard>,
3590 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3591 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3592 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3593 #[cfg(feature = "dig2-station-probe")]
3594 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3595 materialization_id: Option<MaterializationId>,
3596}
3597
3598impl PreparedSpawn<'_> {
3599 fn retain_session_guards(&mut self) {
3601 if let Some(selection) = self.environment_selection.take() {
3602 selection.retain();
3603 }
3604 if let Some(overlay) = self.instance_overlay.take() {
3605 overlay.retain();
3606 }
3607 if let Some(guard) = self.materialization_guard.take() {
3608 guard.retain();
3609 }
3610 }
3611
3612 fn retain_launch_guards(&mut self) {
3614 if let Some(overlay) = self.harness_mcp_overlay.take() {
3615 overlay.retain();
3616 }
3617 #[cfg(feature = "dig2-station-probe")]
3618 if let Some(guard) = self.browser_station_lease_guard.take() {
3619 guard.retain();
3620 }
3621 }
3622}
3623
3624trait SpawnPreparer {
3628 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3629 fn select_environment(
3631 &self,
3632 instance_id: AgentInstanceId,
3633 provider: &AgentId,
3634 mode: SessionMode,
3635 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3636 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3637 fn install_launch_overlay(
3639 &self,
3640 instance_id: AgentInstanceId,
3641 overlay: PreparedNativeLaunchOverlay,
3642 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3643}
3644
3645impl SpawnPreparer for NodeShared {
3646 fn select_environment(
3647 &self,
3648 instance_id: AgentInstanceId,
3649 provider: &AgentId,
3650 mode: SessionMode,
3651 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3652 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3653 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3654 }
3655
3656 fn install_launch_overlay(
3657 &self,
3658 instance_id: AgentInstanceId,
3659 overlay: PreparedNativeLaunchOverlay,
3660 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3661 self.install_prepared_launch_overlay(instance_id, overlay)
3662 }
3663
3664 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3665 let SpawnPrepareRequest {
3666 instance_id,
3667 address,
3668 provider,
3669 mode,
3670 environment_profile,
3671 bundle,
3672 context,
3673 managed_authority,
3674 harness_mcp,
3675 approval_level,
3676 network_allowlist,
3677 browser_profile_id,
3678 } = request;
3679 #[cfg(feature = "dig2-station-probe")]
3684 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3685 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3686 match acquire_lease(
3687 &self.browser_station_leases,
3688 profile_id,
3689 instance_id,
3690 unix_time_ms(),
3691 ) {
3692 Ok(guard) => Some(guard),
3693 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3694 return Err(failure(
3695 NodeFailureCode::BrowserStationProfileBusy,
3696 "dig2browser station profile is already leased by another session",
3697 ));
3698 }
3699 Err(BrowserStationLeaseError::CapacityExceeded) => {
3700 return Err(failure(
3701 NodeFailureCode::BrowserStationProfileBusy,
3702 "dig2browser station profile lease capacity exhausted",
3703 ));
3704 }
3705 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3706 return Err(failure(
3707 NodeFailureCode::BrowserStationProfileBusy,
3708 "session already holds a dig2browser station profile lease",
3709 ));
3710 }
3711 }
3712 } else {
3713 None
3714 };
3715 #[cfg(not(feature = "dig2-station-probe"))]
3716 let _ = browser_profile_id;
3717 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3718 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3727 {
3728 return Err(failure(
3729 NodeFailureCode::BindingMismatch,
3730 "harness MCP reservation does not match the exact PTY/ACP provider",
3731 ));
3732 }
3733 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3734 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3735 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3736 })?;
3737 let launch = prepared.launch();
3742 let mut env_entries = vec![
3743 (
3744 OsString::from(launch.endpoint_env.as_str()),
3745 prepared.endpoint().as_os_str().to_os_string(),
3746 ),
3747 (
3748 OsString::from(launch.token_env.as_str()),
3749 prepared.token().expose().into(),
3750 ),
3751 (
3752 OsString::from(launch.program_env.as_str()),
3753 prepared.helper_program().as_os_str().to_os_string(),
3754 ),
3755 ];
3756 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3757 env_entries.push(trace_entry);
3758 }
3759 if let Some(door) = crate::door::door_listen_addr() {
3760 env_entries.push((
3761 OsString::from("HATCHERY_NODE_DOOR"),
3762 OsString::from(door.to_string()),
3763 ));
3764 let session_key = format!(
3768 "pty-{}-{}-{}",
3769 self.incarnation_id,
3770 address.workspace_id.as_str(),
3771 address.session.instance_id.0
3772 );
3773 env_entries.push((
3774 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3775 OsString::from(session_key),
3776 ));
3777 }
3778 let overlay = NativeMcpServerLaunchOverlay::new(
3779 provider.clone(),
3780 launch.server_name.as_str(),
3781 prepared.helper_program().as_os_str().to_os_string(),
3782 launch.args.iter().map(OsString::from).collect(),
3783 env_entries,
3784 launch.scrub_env.iter().map(OsString::from).collect(),
3785 ).map_err(|_| failure(
3786 NodeFailureCode::HarnessMcpUnavailable,
3787 "harness MCP launch overlay is unavailable",
3788 ))?;
3789 control
3790 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3791 .map_err(|_| failure(
3792 NodeFailureCode::HarnessMcpUnavailable,
3793 "harness MCP launch overlay could not be installed",
3794 ))?;
3795 tracing::info!(
3801 node_id = %self.node_id,
3802 instance_id = ?instance_id,
3803 provider = %provider,
3804 mode = ?mode,
3805 "harness MCP overlay installed for spawn",
3806 );
3807 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3808 } else {
3809 None
3810 };
3811 let prepared_materialization = self.prepare_session_materialization(
3812 &address,
3813 &provider,
3814 mode,
3815 environment_profile,
3816 bundle,
3817 context,
3818 managed_authority.cloned(),
3819 )?;
3820 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3821 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3822 None => (None, None),
3823 };
3824 let materialization_id = materialization_guard
3825 .as_ref()
3826 .and_then(SessionMaterializationGuard::id)
3827 .cloned();
3828 let mut environment_selection = SpawnPreparer::select_environment(self,
3829 instance_id,
3830 &provider,
3831 mode,
3832 environment_profile,
3833 )?;
3834 let provider_home = materialization_guard
3837 .as_ref()
3838 .and_then(SessionMaterializationGuard::provider_home);
3839 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3840 &provider,
3841 mode,
3842 approval_level,
3843 network_allowlist,
3844 provider_home,
3845 )?;
3846 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3847 (prepared, args) if args.is_empty() => prepared,
3848 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3849 overlay.append_extra_args(args).map_err(|_| {
3850 failure(
3851 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3852 "network allowlist overlay could not merge with instance launch args",
3853 )
3854 })?;
3855 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3856 }
3857 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3858 Some(PreparedNativeLaunchOverlay::Instance(
3859 overlay
3860 .into_instance_with_extra_args(args)
3861 .map_err(|_| {
3862 failure(
3863 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3864 "network allowlist overlay could not merge with environment overlay",
3865 )
3866 })?,
3867 ))
3868 }
3869 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3870 NativeInstanceLaunchOverlay::new(
3871 provider.clone(),
3872 match mode {
3873 SessionMode::Pty => TransportKind::Pty,
3874 SessionMode::Acp => TransportKind::Acp,
3875 SessionMode::Inline => TransportKind::Pipe,
3876 },
3877 Vec::new(),
3878 args,
3879 )
3880 .map_err(|_| {
3881 failure(
3882 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3883 "network allowlist overlay is invalid for this transport",
3884 )
3885 })?,
3886 )),
3887 };
3888 #[cfg(unix)]
3891 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3893 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3894 });
3895 #[cfg(unix)]
3896 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3897 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3898 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3899 let uid = unsafe { libc_geteuid() };
3900 let handle = proxy.reserve(&session_key, m4a_provider, uid);
3901 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3902 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3903 Some(vec![
3904 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3905 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3906 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3907 ])
3908 }
3909 _ => None,
3910 };
3911 #[cfg(not(unix))]
3912 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3913 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3914 (SessionMode::Pty, _, _, Some(identity)) => {
3915 let merged = match environment_overlay {
3916 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3917 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3918 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3919 }
3920 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3921 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3922 }
3923 }
3924 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3925 Some(merged)
3926 }
3927 (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3928 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3929 match accounts.provision(m4a_provider, &session_key) {
3930 Ok(account) => {
3931 let identity = account.env_mutations();
3932 let merged = match environment_overlay {
3933 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3934 .map(PreparedNativeLaunchOverlay::Instance),
3935 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3936 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3937 }
3938 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3939 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3940 }
3941 }
3942 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3943 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3944 self.m4a_live
3945 .lock()
3946 .unwrap_or_else(|p| p.into_inner())
3947 .insert(account.nick.clone(), (instance_id, provider.clone()));
3948 Some(merged)
3949 }
3950 Err(error) => {
3951 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3952 environment_overlay
3953 }
3954 }
3955 }
3956 (SessionMode::Pty, Some(_), None, _) => {
3957 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3958 environment_overlay
3959 }
3960 _ => environment_overlay,
3961 };
3962 let mut instance_overlay = environment_overlay
3963 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
3964 .transpose()?
3965 .flatten();
3966 Ok(PreparedSpawn {
3967 instance_overlay,
3968 environment_selection,
3969 materialization_guard,
3970 harness_mcp_overlay,
3971 #[cfg(feature = "dig2-station-probe")]
3972 browser_station_lease_guard,
3973 materialization_id,
3974 })
3975 }
3976}
3977
3978struct SessionRecords {
3979 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
3980}
3981
3982struct SpawnIdempotencyCache {
3983 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
3984}
3985
3986struct SpawnIdempotencyEntry {
3987 value: SpawnIdempotencyValue,
3988 expires_at: Instant,
3989}
3990
3991enum SpawnIdempotencyValue {
3992 Standard {
3993 spec: SpawnSpec,
3994 result: Result<ResolvedSpawnReceipt, NodeFailure>,
3995 },
3996 Managed {
3997 request: ManagedWorktreeSpawnRequest,
3998 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
3999 },
4000 HarnessMcp,
4001}
4002
4003enum DurableManagedSpawnReplayDecision {
4004 Reserved,
4005 PendingLinked,
4006 Committed(ManagedWorktreeSpawnReceipt),
4007}
4008
4009impl SpawnIdempotencyValue {
4010 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4011 match self {
4012 Self::Standard { result, .. } => result
4013 .as_ref()
4014 .ok()
4015 .and_then(|receipt| receipt.context.as_ref()),
4016 Self::Managed { result, .. } => result
4017 .as_ref()
4018 .ok()
4019 .and_then(|receipt| receipt.spawn.context.as_ref()),
4020 Self::HarnessMcp => None,
4021 }
4022 .is_some_and(|receipt| &receipt.id == context_id)
4023 }
4024}
4025
4026#[derive(Clone, Copy)]
4027struct ControllerLease {
4028 connection_id: u64,
4029 expires_at: Instant,
4030}
4031
4032struct NodeEventHistory {
4033 last_sequence: u64,
4034 replay_floor_sequence: u64,
4035 events: VecDeque<NodeEventEnvelope>,
4036 record_providers: BTreeMap<SessionRecordId, AgentId>,
4037 removed_record_providers: BTreeMap<u64, AgentId>,
4038}
4039
4040impl NodeEventHistory {
4041 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4042 Self {
4043 last_sequence: 0,
4044 replay_floor_sequence: 1,
4045 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4046 record_providers,
4047 removed_record_providers: BTreeMap::new(),
4048 }
4049 }
4050}
4051
4052impl NodeShared {
4053 fn new_with_incarnation(
4054 handle: Gate4AgentHandle,
4055 access_token: String,
4056 node_id: NodeId,
4057 incarnation_id: NodeIncarnationId,
4058 workspaces: Vec<WorkspaceConfig>,
4059 enabled_providers: Vec<AgentId>,
4060 provider_runtime_statuses: ProviderRuntimeStatuses,
4061 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4062 provider_contracts: Vec<ProviderContractSupport>,
4063 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4064 spawn_profiles: SpawnProfileRegistry,
4065 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4066 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4067 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4068 state_path: Option<PathBuf>,
4069 records: Vec<ManagedSessionRecord>,
4070 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4071 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4072 materializations: Vec<MaterializationOwnershipRecord>,
4073 persistence_warning: Option<String>,
4074 input_settle_timeout_ms: u64,
4075 ) -> Self {
4076 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4077 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4078 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4079 let (harness_mcp_proxy_event_tx, _) =
4080 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4081 let record_providers = records
4082 .iter()
4083 .map(|record| (record.record_id.clone(), record.provider.clone()))
4084 .collect();
4085 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4086 .iter()
4087 .map(|workspace| {
4088 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4089 })
4090 .collect();
4091 for lease in &managed_worktrees {
4092 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4093 }
4094 let managed_worktree_profiles = workspaces.iter()
4095 .map(|workspace| (
4096 workspace.workspace_id.clone(),
4097 workspace.managed_worktree_profiles.clone(),
4098 ))
4099 .collect();
4100 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4101 managed_worktrees,
4102 managed_worktree_tombstones,
4103 ).expect("durable managed worktree registry was validated while loading");
4104 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4105 managed_worktrees.reattach_record_holders(
4106 &managed_worktree_record_holders(&records),
4107 unix_time_ms(),
4108 );
4109 Self {
4110 handle,
4111 access_token,
4112 node_id,
4113 incarnation_id,
4114 started_at_unix_ms: std::time::SystemTime::now()
4115 .duration_since(std::time::UNIX_EPOCH)
4116 .unwrap_or_default()
4117 .as_millis()
4118 .min(u64::MAX as u128) as u64,
4119 workspaces: RwLock::new(
4120 workspaces
4121 .into_iter()
4122 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4123 .collect(),
4124 ),
4125 worktree_service_modes,
4126 managed_worktree_profiles,
4127 managed_worktrees: Mutex::new(managed_worktrees),
4128 enabled_providers,
4129 provider_runtime_statuses,
4130 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4131 provider_runtime_monitor,
4132 provider_contracts,
4133 provider_adapter_contracts,
4134 spawn_profiles,
4135 environment_profiles: RwLock::new(BTreeMap::new()),
4136 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4137 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4138 #[cfg(feature = "dig2-station-probe")]
4139 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4140 bundle_catalog: RwLock::new(BundleCatalog::default()),
4141 delivery_store: Mutex::new(None),
4142 context_pack_store: Mutex::new(None),
4143 context_catalog: RwLock::new(ContextPackCatalog::default()),
4144 native_launch_profile_control,
4145 harness_mcp_registry: None,
4146 native_session_catalog,
4147 session_environment_materializer,
4148 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4149 m4a_live: Mutex::new(BTreeMap::new()),
4150 #[cfg(unix)]
4151 signer_proxy: std::sync::OnceLock::new(),
4152 materializations: Mutex::new(
4153 materializations
4154 .into_iter()
4155 .map(|record| (record.id().clone(), record))
4156 .collect(),
4157 ),
4158 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4159 entries: BTreeMap::new(),
4160 }),
4161 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4162 controller: Mutex::new(None),
4163 provisional_spawns: Mutex::new(BTreeMap::new()),
4164 pending_acp_options: Mutex::new(BTreeMap::new()),
4165 history: Mutex::new(NodeEventHistory::new(record_providers)),
4166 event_tx,
4167 terminal_event_tx,
4168 agent_stream_event_tx,
4169 harness_mcp_proxy_event_tx,
4170 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4171 pty_input_wake: tokio::sync::Notify::new(),
4172 pty_input_marks: Mutex::new(BTreeMap::new()),
4173 next_connection_id: AtomicU64::new(1),
4174 next_instance_id: AtomicU64::new(1),
4175 next_command_id: AtomicU64::new(1),
4176 shutdown: AtomicBool::new(false),
4177 shutdown_notify: Notify::new(),
4178 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4179 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4180 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4181 mutation_gate: AsyncMutex::new(()),
4182 session_bindings: Mutex::new(BTreeMap::new()),
4183 session_records: Mutex::new(SessionRecords {
4184 records: records
4185 .into_iter()
4186 .map(|record| (record.record_id.clone(), record))
4187 .collect(),
4188 }),
4189 state_path,
4190 persistence_error: RwLock::new(
4191 persistence_warning
4192 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4193 ),
4194 state_transaction: Mutex::new(()),
4195 input_settle_timeout_ms,
4196 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4197 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4198 shell_efficiency_profile: OnceLock::new(),
4199 native_session_gauge: AtomicUsize::new(0),
4200 connection_loop_iterations: AtomicU64::new(0),
4201 connection_events_sent: AtomicU64::new(0),
4202 rejected_commands_total: AtomicU64::new(0),
4203 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4204 drive_loop_iterations_total: AtomicU64::new(0),
4205 drive_loop_iterations_idle: AtomicU64::new(0),
4206 session_record_retention: SessionRecordRetentionConfig::default(),
4207 retired_records_total: AtomicU64::new(0),
4208 #[cfg(feature = "fixture")]
4209 fixture_semantic_hook_policy: false,
4210 #[cfg(feature = "fixture")]
4211 fixture_spawn_managed_worktree_v2_failure_probe:
4212 SpawnManagedWorktreeV2FailureProbe::default(),
4213 }
4214 }
4215
4216 #[cfg(test)]
4217 fn new(
4218 handle: Gate4AgentHandle,
4219 access_token: String,
4220 node_id: NodeId,
4221 workspaces: Vec<WorkspaceConfig>,
4222 enabled_providers: Vec<AgentId>,
4223 ) -> Self {
4224 Self::new_with_incarnation(
4225 handle,
4226 access_token,
4227 node_id,
4228 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4229 workspaces,
4230 enabled_providers,
4231 ProviderRuntimeStatuses::default(),
4232 None,
4233 Vec::new(),
4234 Vec::new(),
4235 SpawnProfileRegistry::default(),
4236 None,
4237 None,
4238 None,
4239 None,
4240 Vec::new(),
4241 Vec::new(),
4242 Vec::new(),
4243 Vec::new(),
4244 None,
4245 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4246 )
4247 }
4248
4249 fn persistence_error(&self) -> Option<String> {
4250 self.persistence_error
4251 .read()
4252 .unwrap_or_else(|poisoned| poisoned.into_inner())
4253 .clone()
4254 }
4255
4256 async fn admit_provider_runtime(
4257 &self,
4258 provider: &AgentId,
4259 requirement: ProviderRuntimeRequirement,
4260 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4261 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4262 let pipe_transport = monitor.supports_pipe_transport(provider);
4263 let acp_transport = monitor.supports_acp_transport(provider);
4264 let provider = provider.clone();
4265 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4266 let (status, admission) = timeout(
4267 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4268 refresh,
4269 )
4270 .await
4271 .map_err(|_| failure(
4272 NodeFailureCode::BackendBusy,
4273 "provider runtime probe exceeded its bounded deadline",
4274 ))?
4275 .map_err(|_| failure(
4276 NodeFailureCode::BackendOperationFailed,
4277 "provider runtime probe task failed",
4278 ))?;
4279 if let Some(status) = status {
4280 self
4281 .provider_runtime_status_updates
4282 .write()
4283 .unwrap_or_else(|poisoned| poisoned.into_inner())
4284 .insert(status.provider().clone(), status);
4285 }
4286 admission.and_then(|policy| {
4287 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4288 })
4289 } else {
4290 crate::provider_runtime::admit_status(
4296 &self.provider_runtime_statuses,
4297 provider,
4298 false,
4299 false,
4300 requirement,
4301 )
4302 };
4303 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4304 admission.map_err(|error| match error {
4305 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4306 NodeFailureCode::BackendOperationFailed,
4307 "provider launcher is unavailable",
4308 ),
4309 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4310 NodeFailureCode::UnsupportedCapability,
4311 "provider semantic capability is not verified",
4312 ),
4313 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4314 NodeFailureCode::BackendBusy,
4315 "provider runtime probe is already in progress",
4316 ),
4317 })
4318 }
4319
4320 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4326 match self.provider_runtime_monitor.as_deref() {
4327 Some(monitor) => monitor.supports_pipe_transport(provider),
4328 None => false,
4329 }
4330 }
4331
4332 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4338 match self.provider_runtime_monitor.as_deref() {
4339 Some(monitor) => monitor.supports_acp_transport(provider),
4340 None => false,
4341 }
4342 }
4343
4344 fn admit_declared_hook_semantics(
4360 &self,
4361 provider: &AgentId,
4362 policy: ProviderRuntimePolicy,
4363 ) -> ProviderRuntimePolicy {
4364 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4365 return policy;
4366 }
4367 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4368 contract.provider == *provider && contract.family == AdapterFamily::Hook
4369 });
4370 if !declares_hook {
4371 return policy;
4372 }
4373 ProviderRuntimePolicy::new(
4374 policy.raw_pty_lifecycle,
4375 policy.semantic_readiness,
4376 policy.structured_prompt,
4377 policy.provider_session_identity,
4378 policy.semantic_resume,
4379 true,
4380 )
4381 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4382 }
4383
4384 fn resolve_network_allowlist_overlay_args(
4399 &self,
4400 provider: &AgentId,
4401 mode: SessionMode,
4402 approval_level: ApprovalLevel,
4403 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4404 provider_home: Option<&Path>,
4405 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4406 let Some(allowlist_id) = network_allowlist else {
4407 return Ok(Vec::new());
4408 };
4409 let catalog = self
4410 .network_allowlist_catalog
4411 .read()
4412 .unwrap_or_else(|poisoned| poisoned.into_inner());
4413 let Some(entry) = catalog.get(allowlist_id) else {
4414 return Err(failure(
4415 NodeFailureCode::UnknownNetworkAllowlist,
4416 "spawn network allowlist is unavailable on this node",
4417 ));
4418 };
4419
4420 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4422 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4423 |message| {
4424 failure(
4425 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4426 &message,
4427 )
4428 },
4429 )?;
4430 if !matches!(mode, SessionMode::Pty) {
4431 return Err(failure(
4432 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4433 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4434 ));
4435 }
4436 let Some(home) = provider_home else {
4437 return Err(failure(
4438 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4439 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4440 ));
4441 };
4442 let domains =
4443 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4444 if domains.is_empty() {
4445 return Err(failure(
4446 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4447 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4448 ));
4449 }
4450 let settings_path = home.join(
4451 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4452 );
4453 let json =
4454 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4455 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4456 |_| {
4457 failure(
4458 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4459 "Claude station network settings sidecar could not be written under ProviderHome",
4460 )
4461 },
4462 )?;
4463 let overlay =
4464 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4465 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4466 }
4467
4468 let Some(native) = entry.provider_native.as_ref() else {
4469 return Ok(Vec::new());
4470 };
4471 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4472 provider.as_str(),
4473 approval_level,
4474 native,
4475 )
4476 .map_err(|message| {
4477 failure(
4478 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4479 &message,
4480 )
4481 })?;
4482 if overlay.is_empty() {
4483 return Ok(Vec::new());
4484 }
4485 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4486 return Err(failure(
4487 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4488 "Codex network_access -c overlay requires PTY or ACP transport",
4489 ));
4490 }
4491 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4492 }
4493
4494 fn resolve_codex_network_access_overlay_args(
4496 &self,
4497 provider: &AgentId,
4498 mode: SessionMode,
4499 approval_level: ApprovalLevel,
4500 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4501 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4502 self.resolve_network_allowlist_overlay_args(
4503 provider,
4504 mode,
4505 approval_level,
4506 network_allowlist,
4507 None,
4508 )
4509 }
4510
4511 fn resolve_environment_profile(
4512 &self,
4513 resolved: &ResolvedSpawnSpec,
4514 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4515 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4521 let catalog = self
4522 .network_allowlist_catalog
4523 .read()
4524 .unwrap_or_else(|poisoned| poisoned.into_inner());
4525 let Some(entry) = catalog.get(allowlist_id) else {
4526 return Err(failure(
4527 NodeFailureCode::UnknownNetworkAllowlist,
4528 "spawn network allowlist is unavailable on this node",
4529 ));
4530 };
4531 if let Some(native) = entry.provider_native.as_ref() {
4534 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4535 resolved.provider.as_str(),
4536 resolved.approval_level,
4537 native,
4538 ) {
4539 return Err(failure(
4540 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4541 &message,
4542 ));
4543 }
4544 }
4545 }
4546 #[cfg(feature = "dig2-station-probe")]
4555 if resolved.browser_profile_id.is_some() {
4556 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4557 match probe_station_reachable() {
4558 Ok(()) => {}
4559 Err(StationProbeError::UnsupportedPlatform)
4560 | Err(StationProbeError::InvalidPipeSuffix) => {
4561 return Err(failure(
4562 NodeFailureCode::BrowserStationProbeUnavailable,
4563 "dig2browser station probe unavailable on this platform",
4564 ));
4565 }
4566 Err(StationProbeError::Unreachable) => {
4567 return Err(failure(
4568 NodeFailureCode::BrowserStationUnreachable,
4569 "dig2browser station named-pipe is unreachable",
4570 ));
4571 }
4572 }
4573 }
4574 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4575 return Ok(None);
4576 };
4577 let profiles = self
4578 .environment_profiles
4579 .read()
4580 .unwrap_or_else(|poisoned| poisoned.into_inner());
4581 let binding = profiles.get(profile_id).ok_or_else(|| {
4582 failure(
4583 NodeFailureCode::UnknownEnvironmentProfile,
4584 "spawn environment profile is unavailable on this node",
4585 )
4586 })?;
4587 if binding.provider != resolved.provider
4588 || binding.native_profile_id(resolved.mode).is_none()
4589 {
4590 return Err(failure(
4591 NodeFailureCode::EnvironmentProfileBindingMismatch,
4592 "spawn environment profile does not match the provider and mode",
4593 ));
4594 }
4595 Ok(Some(ResolvedEnvironmentProfileReceipt {
4599 profile_id: binding.id.clone(),
4600 profile_revision: binding.revision.clone(),
4601 network_allowlist: resolved.network_allowlist.clone(),
4602 browser_profile_id: resolved.browser_profile_id.clone(),
4603 }))
4604 }
4605
4606 fn resolve_bundle(
4607 &self,
4608 resolved: &ResolvedSpawnSpec,
4609 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4610 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4611 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4612 return Ok(None);
4613 };
4614 let catalog = self.bundle_catalog
4615 .read()
4616 .unwrap_or_else(|poisoned| poisoned.into_inner());
4617 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4618 failure(
4619 NodeFailureCode::UnknownBundle,
4620 "spawn bundle is unavailable on this node",
4621 )
4622 })?;
4623 self.bundle_layout(
4624 &resolved.provider,
4625 resolved.mode,
4626 environment_profile,
4627 bundle,
4628 )?;
4629 Ok(Some(bundle.receipt()))
4630 }
4631
4632 fn begin_delivery_stage(
4633 &self,
4634 manifest: DeliveryBundleManifestV2,
4635 ) -> Result<NodeResponse, NodeFailure> {
4636 let mut store = self
4637 .delivery_store
4638 .lock()
4639 .unwrap_or_else(|poisoned| poisoned.into_inner());
4640 let store = store.as_mut().ok_or_else(|| {
4641 failure(
4642 NodeFailureCode::DeliveryStageStorageFailed,
4643 "node delivery store is unavailable",
4644 )
4645 })?;
4646 let begun = store.begin(manifest).map_err(delivery_failure)?;
4647 Ok(NodeResponse::DeliveryStageBegun {
4648 stage_id: begun.stage_id,
4649 manifest_digest: begun.manifest_digest,
4650 missing_blobs: begun.missing_blobs,
4651 })
4652 }
4653
4654 fn put_delivery_blob_chunk(
4655 &self,
4656 stage_id: DeliveryStageId,
4657 blob_digest: DeliveryBlobDigestV1,
4658 offset: u64,
4659 chunk_hex: DeliveryBlobChunkHexV1,
4660 ) -> Result<NodeResponse, NodeFailure> {
4661 let mut store = self
4662 .delivery_store
4663 .lock()
4664 .unwrap_or_else(|poisoned| poisoned.into_inner());
4665 let store = store.as_mut().ok_or_else(|| {
4666 failure(
4667 NodeFailureCode::DeliveryStageStorageFailed,
4668 "node delivery store is unavailable",
4669 )
4670 })?;
4671 let accepted = store
4672 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4673 .map_err(delivery_failure)?;
4674 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4675 stage_id: accepted.stage_id,
4676 blob_digest: accepted.blob_digest,
4677 next_offset: accepted.next_offset,
4678 })
4679 }
4680
4681 fn commit_delivery_stage(
4682 &self,
4683 stage_id: DeliveryStageId,
4684 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4685 let mut store = self
4686 .delivery_store
4687 .lock()
4688 .unwrap_or_else(|poisoned| poisoned.into_inner());
4689 let store = store.as_mut().ok_or_else(|| {
4690 failure(
4691 NodeFailureCode::DeliveryStageStorageFailed,
4692 "node delivery store is unavailable",
4693 )
4694 })?;
4695 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4696 let mut catalog = self
4697 .bundle_catalog
4698 .write()
4699 .unwrap_or_else(|poisoned| poisoned.into_inner());
4700 let mut next_catalog = catalog.clone();
4701 next_catalog
4702 .insert_idempotent(prepared.bundle().clone())
4703 .map_err(|_| {
4704 failure(
4705 NodeFailureCode::DeliveryStageConflict,
4706 "delivery bundle identity conflicts with installed content",
4707 )
4708 })?;
4709 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4710 *catalog = next_catalog;
4711 Ok(receipt)
4712 }
4713
4714 fn abort_delivery_stage(
4715 &self,
4716 stage_id: &DeliveryStageId,
4717 ) -> Result<(), NodeFailure> {
4718 let mut store = self
4719 .delivery_store
4720 .lock()
4721 .unwrap_or_else(|poisoned| poisoned.into_inner());
4722 let store = store.as_mut().ok_or_else(|| {
4723 failure(
4724 NodeFailureCode::DeliveryStageStorageFailed,
4725 "node delivery store is unavailable",
4726 )
4727 })?;
4728 store.abort(stage_id).map_err(delivery_failure)
4729 }
4730
4731 fn resolve_context(
4732 &self,
4733 resolved: &ResolvedSpawnSpec,
4734 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4735 let Some(context_id) = resolved.context_id.as_ref() else {
4736 return Ok(None);
4737 };
4738 self.context_catalog
4739 .read()
4740 .unwrap_or_else(|poisoned| poisoned.into_inner())
4741 .get(context_id)
4742 .map(|context| context.receipt().clone())
4743 .ok_or_else(|| {
4744 failure(
4745 NodeFailureCode::UnknownContextPack,
4746 "spawn context pack is unavailable on this node",
4747 )
4748 })
4749 .map(Some)
4750 }
4751
4752 fn bundle_layout(
4753 &self,
4754 provider: &AgentId,
4755 mode: SessionMode,
4756 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4757 bundle: &NodeBundle,
4758 ) -> Result<BundleProviderLayout, NodeFailure> {
4759 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4760 failure(
4761 NodeFailureCode::BundleBindingMismatch,
4762 "spawn bundle does not match the provider and mode",
4763 )
4764 })?;
4765 if layout != BundleProviderLayout::Codex {
4766 return Ok(layout);
4767 }
4768 let profile = environment_profile.and_then(|receipt| {
4769 let profiles = self
4770 .environment_profiles
4771 .read()
4772 .unwrap_or_else(|poisoned| poisoned.into_inner());
4773 let binding = profiles.get(&receipt.profile_id)?;
4774 if binding.revision != receipt.profile_revision
4775 || &binding.provider != provider
4776 || binding.native_profile_id(mode).is_none()
4777 {
4778 return None;
4779 }
4780 self.environment_materialization_profiles
4781 .read()
4782 .unwrap_or_else(|poisoned| poisoned.into_inner())
4783 .get(&receipt.profile_id)
4784 .cloned()
4785 });
4786 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4787 return Err(failure(
4788 NodeFailureCode::BundleBindingMismatch,
4789 "Codex bundle requires an exact isolated CODEX_HOME profile",
4790 ));
4791 }
4792 Ok(layout)
4793 }
4794
4795 fn select_environment_profile(
4796 &self,
4797 instance_id: AgentInstanceId,
4798 provider: &AgentId,
4799 mode: SessionMode,
4800 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4801 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4802 let Some(environment_profile) = environment_profile else {
4803 return Ok(None);
4804 };
4805 let native_profile_id = {
4806 let profiles = self
4807 .environment_profiles
4808 .read()
4809 .unwrap_or_else(|poisoned| poisoned.into_inner());
4810 let binding = profiles
4811 .get(&environment_profile.profile_id)
4812 .ok_or_else(|| {
4813 failure(
4814 NodeFailureCode::UnknownEnvironmentProfile,
4815 "session environment profile is unavailable on this node",
4816 )
4817 })?;
4818 if &binding.revision != &environment_profile.profile_revision
4819 || &binding.provider != provider
4820 {
4821 return Err(failure(
4822 NodeFailureCode::EnvironmentProfileBindingMismatch,
4823 "session environment profile revision or provider changed",
4824 ));
4825 }
4826 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4827 failure(
4828 NodeFailureCode::EnvironmentProfileBindingMismatch,
4829 "session environment profile does not support this mode",
4830 )
4831 })?
4832 };
4833 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4834 failure(
4835 NodeFailureCode::BackendOperationFailed,
4836 "native environment profile controller is unavailable",
4837 )
4838 })?;
4839 control
4840 .select_native_launch_profile(instance_id, native_profile_id)
4841 .map_err(|_| {
4842 failure(
4843 NodeFailureCode::BackendOperationFailed,
4844 "native environment profile selection failed",
4845 )
4846 })?;
4847 Ok(Some(NativeEnvironmentSelectionGuard {
4848 control: Some(control),
4849 instance_id,
4850 }))
4851 }
4852
4853 fn install_prepared_launch_overlay(
4854 &self,
4855 instance_id: AgentInstanceId,
4856 overlay: PreparedNativeLaunchOverlay,
4857 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4858 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4859 failure(
4860 NodeFailureCode::BackendOperationFailed,
4861 "native launch profile controller is unavailable",
4862 )
4863 })?;
4864 match overlay {
4865 PreparedNativeLaunchOverlay::Environment(overlay) => {
4866 control
4867 .install_native_launch_environment_overlay(instance_id, overlay)
4868 .map_err(|_| failure(
4869 NodeFailureCode::BackendOperationFailed,
4870 "session environment overlay installation failed",
4871 ))?;
4872 Ok(None)
4873 }
4874 PreparedNativeLaunchOverlay::Instance(overlay) => {
4875 control
4876 .install_native_instance_launch_overlay(instance_id, overlay)
4877 .map_err(|_| failure(
4878 NodeFailureCode::BundleBindingMismatch,
4879 "session bundle launch overlay installation failed",
4880 ))?;
4881 Ok(Some(NativeInstanceOverlayGuard {
4882 control: Some(control),
4883 instance_id,
4884 }))
4885 }
4886 }
4887 }
4888
4889 fn environment_materialization_profile(
4890 &self,
4891 environment_profile: &ResolvedEnvironmentProfileReceipt,
4892 ) -> Option<NodeSessionMaterializationProfile> {
4893 self.environment_materialization_profiles
4894 .read()
4895 .unwrap_or_else(|poisoned| poisoned.into_inner())
4896 .get(&environment_profile.profile_id)
4897 .cloned()
4898 }
4899
4900 fn materialization_profile(
4901 &self,
4902 provider: &AgentId,
4903 mode: SessionMode,
4904 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4905 bundle: Option<&ResolvedBundleReceipt>,
4906 context: Option<&ResolvedContextPackReceipt>,
4907 ) -> Result<Option<(
4908 NodeSessionMaterializationProfile,
4909 bool,
4910 Option<BundleProviderLayout>,
4911 )>, NodeFailure> {
4912 let environment = environment_profile
4913 .and_then(|receipt| self.environment_materialization_profile(receipt));
4914 let (bundle, bundle_layout) = match bundle {
4915 Some(receipt) => {
4916 let catalog = self.bundle_catalog.read()
4917 .unwrap_or_else(|poisoned| poisoned.into_inner());
4918 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4919 NodeFailureCode::UnknownBundle,
4920 "session bundle is unavailable on this node",
4921 ))?;
4922 if installed.receipt() != *receipt {
4923 return Err(failure(
4924 NodeFailureCode::BundleBindingMismatch,
4925 "session bundle revision or digest changed",
4926 ));
4927 }
4928 let layout = self.bundle_layout(
4929 provider,
4930 mode,
4931 environment_profile,
4932 installed,
4933 )?;
4934 (Some(installed.clone()), Some(layout))
4935 }
4936 None => (None, None),
4937 };
4938 let has_environment_overlay = environment.is_some() || context.is_some();
4939 let profile = match (environment, bundle.as_ref(), bundle_layout) {
4940 (None, None, None) => None,
4941 (Some(profile), None, None) => Some(Ok(profile)),
4942 (None, Some(bundle), Some(layout)) => {
4943 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
4944 }
4945 (Some(profile), Some(bundle), Some(layout)) => {
4946 Some(profile.with_bundle(bundle, layout))
4947 }
4948 _ => {
4949 return Err(failure(
4950 NodeFailureCode::BundleBindingMismatch,
4951 "session bundle provider layout is unavailable",
4952 ));
4953 }
4954 };
4955 let profile = profile
4956 .transpose()
4957 .map_err(|_| failure(
4958 NodeFailureCode::BundleMaterializationFailed,
4959 "session bundle materialization profile is invalid",
4960 ))?;
4961 let profile = match (profile, context) {
4962 (Some(profile), Some(receipt)) => {
4963 let catalog = self.context_catalog
4964 .read()
4965 .unwrap_or_else(|poisoned| poisoned.into_inner());
4966 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4967 NodeFailureCode::UnknownContextPack,
4968 "session context pack is unavailable on this node",
4969 ))?;
4970 if pack.receipt() != receipt {
4971 return Err(failure(
4972 NodeFailureCode::ContextPackMaterializationFailed,
4973 "session context pack identity changed",
4974 ));
4975 }
4976 profile.with_context(pack)
4977 }
4978 (None, Some(receipt)) => {
4979 let catalog = self.context_catalog
4980 .read()
4981 .unwrap_or_else(|poisoned| poisoned.into_inner());
4982 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
4983 NodeFailureCode::UnknownContextPack,
4984 "session context pack is unavailable on this node",
4985 ))?;
4986 if pack.receipt() != receipt {
4987 return Err(failure(
4988 NodeFailureCode::ContextPackMaterializationFailed,
4989 "session context pack identity changed",
4990 ));
4991 }
4992 NodeSessionMaterializationProfile::from_context(pack)
4993 }
4994 (Some(profile), None) => Ok(profile),
4995 (None, None) => return Ok(None),
4996 }
4997 .map_err(|_| failure(
4998 NodeFailureCode::ContextPackMaterializationFailed,
4999 "session context pack materialization profile is invalid",
5000 ))?;
5001 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5002 }
5003
5004 fn prepare_session_materialization<'a>(
5005 &'a self,
5006 address: &SessionAddress,
5007 provider: &AgentId,
5008 mode: SessionMode,
5009 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5010 bundle: Option<&ResolvedBundleReceipt>,
5011 context: Option<&ResolvedContextPackReceipt>,
5012 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5013 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5014 let Some((profile, has_environment_overlay, bundle_layout)) =
5015 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5016 else {
5017 return Ok(None);
5018 };
5019 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5020 failure(
5021 NodeFailureCode::BackendOperationFailed,
5022 "session environment materializer is unavailable",
5023 )
5024 })?;
5025 let materialization_failure_code = if context.is_some() {
5026 NodeFailureCode::ContextPackMaterializationFailed
5027 } else if bundle.is_some() {
5028 NodeFailureCode::BundleMaterializationFailed
5029 } else {
5030 NodeFailureCode::BackendOperationFailed
5031 };
5032 let id = MaterializationId::new(format!(
5033 "mat-{}-{}-{}",
5034 self.incarnation_id,
5035 address.session.instance_id.0,
5036 address.session.generation.0,
5037 ))
5038 .map_err(|_| {
5039 failure(
5040 NodeFailureCode::BackendOperationFailed,
5041 "session environment materialization identity failed",
5042 )
5043 })?;
5044 let owner = MaterializationOwner::Session {
5045 incarnation_id: self.incarnation_id,
5046 instance_id: address.session.instance_id,
5047 generation: address.session.generation,
5048 };
5049 let mut ownership = materializer
5050 .begin(
5051 id.clone(),
5052 environment_profile.cloned(),
5053 bundle.cloned(),
5054 context.cloned(),
5055 owner,
5056 managed_lease_id,
5057 &profile,
5058 unix_time_ms(),
5059 )
5060 .map_err(|_| {
5061 failure(
5062 materialization_failure_code,
5063 "session materialization preparation failed",
5064 )
5065 })?;
5066 {
5067 let _transaction = self
5068 .state_transaction
5069 .lock()
5070 .unwrap_or_else(|poisoned| poisoned.into_inner());
5071 let duplicate = self
5072 .materializations
5073 .lock()
5074 .unwrap_or_else(|poisoned| poisoned.into_inner())
5075 .insert(id.clone(), ownership.clone());
5076 if duplicate.is_some() {
5077 return Err(failure(
5078 NodeFailureCode::BackendOperationFailed,
5079 "session environment materialization identity conflict",
5080 ));
5081 }
5082 if let Err(error) = self.persist_state_locked() {
5083 self.materializations
5084 .lock()
5085 .unwrap_or_else(|poisoned| poisoned.into_inner())
5086 .remove(&id);
5087 return Err(persistence_failure(error));
5088 }
5089 }
5090 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5091 Ok(environment) => environment,
5092 Err(_) => {
5093 let transaction = self
5094 .state_transaction
5095 .lock()
5096 .unwrap_or_else(|poisoned| poisoned.into_inner());
5097 self.materializations
5098 .lock()
5099 .unwrap_or_else(|poisoned| poisoned.into_inner())
5100 .insert(id.clone(), ownership.clone());
5101 if self.persist_state_locked().is_err() {
5102 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5103 }
5104 drop(transaction);
5105 if ownership.state() == MaterializationState::CleanupRequired {
5106 let _ = self.cleanup_materialization(&id);
5107 }
5108 return Err(failure(
5109 materialization_failure_code,
5110 "session materialization failed",
5111 ));
5112 }
5113 };
5114 {
5115 let _transaction = self
5116 .state_transaction
5117 .lock()
5118 .unwrap_or_else(|poisoned| poisoned.into_inner());
5119 self.materializations
5120 .lock()
5121 .unwrap_or_else(|poisoned| poisoned.into_inner())
5122 .insert(id.clone(), ownership.clone());
5123 if let Err(error) = self.persist_state_locked() {
5124 drop(_transaction);
5125 let _ = self.cleanup_materialization(&id);
5126 return Err(persistence_failure(error));
5127 }
5128 }
5129 let guard = SessionMaterializationGuard {
5130 shared: self,
5131 id: Some(id),
5132 provider_home: Some(ownership.provider_home().to_path_buf()),
5133 };
5134 let bundle_arguments = match bundle {
5135 Some(receipt) => {
5136 let catalog = self.bundle_catalog.read()
5137 .unwrap_or_else(|poisoned| poisoned.into_inner());
5138 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5139 NodeFailureCode::UnknownBundle,
5140 "session bundle is unavailable on this node",
5141 ))?;
5142 if installed.receipt() != *receipt {
5143 return Err(failure(
5144 NodeFailureCode::BundleBindingMismatch,
5145 "session bundle revision or digest changed",
5146 ));
5147 }
5148 let layout = bundle_layout.ok_or_else(|| failure(
5149 NodeFailureCode::BundleBindingMismatch,
5150 "materialized bundle provider layout is unavailable",
5151 ))?;
5152 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5153 failure(
5154 NodeFailureCode::BundleMaterializationFailed,
5155 "session bundle byte revalidation failed",
5156 )
5157 })?;
5158 Some(bundle_launch_arguments(
5159 layout,
5160 ownership.bundle_root(),
5161 ownership.provider_home(),
5162 ).map_err(|_| failure(
5163 NodeFailureCode::BundleBindingMismatch,
5164 "session bundle launch binding is invalid",
5165 ))?)
5166 }
5167 None => None,
5168 };
5169 if let Some(receipt) = context {
5170 materializer
5171 .revalidate_context(&ownership, receipt)
5172 .map_err(|_| failure(
5173 NodeFailureCode::ContextPackMaterializationFailed,
5174 "session context pack byte revalidation failed",
5175 ))?;
5176 }
5177 let transport = match mode {
5178 SessionMode::Pty => TransportKind::Pty,
5179 SessionMode::Inline => TransportKind::Pipe,
5180 SessionMode::Acp => TransportKind::Acp,
5181 };
5182 let overlay = if let Some(extra_args) = bundle_arguments {
5183 Some(PreparedNativeLaunchOverlay::Instance(
5184 NativeInstanceLaunchOverlay::new(
5185 provider.clone(),
5186 transport,
5187 if has_environment_overlay {
5188 environment
5189 } else {
5190 Vec::new()
5191 },
5192 extra_args,
5193 )
5194 .map_err(|_| {
5195 failure(
5196 NodeFailureCode::BundleBindingMismatch,
5197 "session bundle launch overlay is invalid",
5198 )
5199 })?,
5200 ))
5201 } else if has_environment_overlay {
5202 let context_only = environment_profile.is_none()
5203 && bundle.is_none()
5204 && context.is_some();
5205 let overlay = if context_only {
5206 NativeLaunchEnvironmentOverlay::new_context_root(
5207 provider.clone(),
5208 transport,
5209 environment,
5210 )
5211 .map_err(|_| {
5212 failure(
5213 NodeFailureCode::ContextPackMaterializationFailed,
5214 "context-only launch overlay is invalid",
5215 )
5216 })?
5217 } else {
5218 NativeLaunchEnvironmentOverlay::new(
5219 provider.clone(),
5220 transport,
5221 environment,
5222 )
5223 .map_err(|_| {
5224 failure(
5225 NodeFailureCode::BackendOperationFailed,
5226 "session environment overlay is invalid",
5227 )
5228 })?
5229 };
5230 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5231 } else {
5232 None
5233 };
5234 Ok(Some((overlay, guard)))
5235 }
5236
5237 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5238 let Some(mut ownership) = self
5239 .materializations
5240 .lock()
5241 .unwrap_or_else(|poisoned| poisoned.into_inner())
5242 .get(id)
5243 .cloned()
5244 else {
5245 return Ok(());
5246 };
5247 if ownership.state() != MaterializationState::CleanupRequired {
5248 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5249 failure(
5250 NodeFailureCode::BackendOperationFailed,
5251 "session environment cleanup state is invalid",
5252 )
5253 })?;
5254 }
5255 {
5256 let _transaction = self
5257 .state_transaction
5258 .lock()
5259 .unwrap_or_else(|poisoned| poisoned.into_inner());
5260 let previous = self
5261 .materializations
5262 .lock()
5263 .unwrap_or_else(|poisoned| poisoned.into_inner())
5264 .insert(id.clone(), ownership.clone());
5265 if let Err(error) = self.persist_state_locked() {
5266 let mut materializations = self
5267 .materializations
5268 .lock()
5269 .unwrap_or_else(|poisoned| poisoned.into_inner());
5270 if let Some(previous) = previous {
5271 materializations.insert(id.clone(), previous);
5272 }
5273 return Err(persistence_failure(error));
5274 }
5275 }
5276 let cleanup = self
5277 .session_environment_materializer
5278 .as_ref()
5279 .ok_or(())
5280 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5281 if cleanup.is_err() {
5282 let _ = ownership.mark_recovery_required(unix_time_ms());
5283 self.materializations
5284 .lock()
5285 .unwrap_or_else(|poisoned| poisoned.into_inner())
5286 .insert(id.clone(), ownership);
5287 if self.persist_state().is_err() {
5288 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5289 }
5290 return Err(failure(
5291 NodeFailureCode::BackendOperationFailed,
5292 "session environment cleanup requires recovery",
5293 ));
5294 }
5295 let _transaction = self
5296 .state_transaction
5297 .lock()
5298 .unwrap_or_else(|poisoned| poisoned.into_inner());
5299 self.materializations
5300 .lock()
5301 .unwrap_or_else(|poisoned| poisoned.into_inner())
5302 .remove(id);
5303 if let Err(error) = self.persist_state_locked() {
5304 self.materializations
5305 .lock()
5306 .unwrap_or_else(|poisoned| poisoned.into_inner())
5307 .insert(id.clone(), ownership);
5308 return Err(persistence_failure(error));
5309 }
5310 Ok(())
5311 }
5312
5313 fn resolve_record_materialization(
5314 &self,
5315 record_id: &SessionRecordId,
5316 provider: &AgentId,
5317 mode: SessionMode,
5318 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5319 bundle: Option<&ResolvedBundleReceipt>,
5320 context: Option<&ResolvedContextPackReceipt>,
5321 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5322 let ownership = {
5323 let materializations = self
5324 .materializations
5325 .lock()
5326 .unwrap_or_else(|poisoned| poisoned.into_inner());
5327 let mut matches = materializations.values().filter(|record| {
5328 record.owner()
5329 == &MaterializationOwner::Record {
5330 record_id: record_id.clone(),
5331 }
5332 });
5333 let ownership = matches.next().cloned();
5334 if matches.next().is_some() {
5335 return Err(failure(
5336 NodeFailureCode::BackendOperationFailed,
5337 "managed session has conflicting environment ownership",
5338 ));
5339 }
5340 ownership
5341 };
5342 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5343 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5344 failure(
5345 NodeFailureCode::ContextPackMaterializationFailed,
5346 "session context materializer is unavailable",
5347 )
5348 })?;
5349 let pack = materializer
5350 .revalidate_context(ownership, receipt)
5351 .map_err(|_| {
5352 self.mark_materialization_recovery_required(ownership.id());
5353 failure(
5354 NodeFailureCode::ContextPackMaterializationFailed,
5355 "managed session context pack revalidation failed",
5356 )
5357 })?;
5358 self.context_catalog
5359 .write()
5360 .unwrap_or_else(|poisoned| poisoned.into_inner())
5361 .insert(pack)
5362 .map_err(|_| failure(
5363 NodeFailureCode::ContextPackMaterializationFailed,
5364 "managed session context pack catalog restore failed",
5365 ))?;
5366 }
5367 let materialization = self.materialization_profile(
5368 provider,
5369 mode,
5370 environment_profile,
5371 bundle,
5372 context,
5373 )?;
5374 match (ownership, materialization) {
5375 (None, None) => Ok(None),
5376 (None, Some(_)) | (Some(_), None) => Err(failure(
5377 NodeFailureCode::BackendOperationFailed,
5378 "managed session materialization is unavailable",
5379 )),
5380 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5381 if ownership.environment_profile() != environment_profile
5382 || ownership.bundle() != bundle
5383 || ownership.context() != context
5384 || ownership.state() != MaterializationState::Ready
5385 {
5386 self.mark_materialization_recovery_required(ownership.id());
5387 return Err(failure(
5388 NodeFailureCode::BackendOperationFailed,
5389 "managed session environment ownership requires recovery",
5390 ));
5391 }
5392 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5393 failure(
5394 NodeFailureCode::BackendOperationFailed,
5395 "session environment materializer is unavailable",
5396 )
5397 })?;
5398 if materializer.revalidate(&ownership).is_err() {
5399 self.mark_materialization_recovery_required(ownership.id());
5400 return Err(failure(
5401 NodeFailureCode::BackendOperationFailed,
5402 "managed session environment revalidation failed",
5403 ));
5404 }
5405 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5406 let catalog = self.bundle_catalog.read()
5407 .unwrap_or_else(|poisoned| poisoned.into_inner());
5408 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5409 return Err(failure(
5410 NodeFailureCode::UnknownBundle,
5411 "managed session bundle is unavailable",
5412 ));
5413 };
5414 let layout = bundle_layout.ok_or_else(|| failure(
5415 NodeFailureCode::BundleBindingMismatch,
5416 "record bundle provider layout is unavailable",
5417 ))?;
5418 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5419 drop(catalog);
5420 self.mark_materialization_recovery_required(ownership.id());
5421 return Err(failure(
5422 NodeFailureCode::BundleMaterializationFailed,
5423 "managed session bundle revalidation failed",
5424 ));
5425 }
5426 Some(bundle_launch_arguments(
5427 layout,
5428 ownership.bundle_root(),
5429 ownership.provider_home(),
5430 ).map_err(|_| failure(
5431 NodeFailureCode::BundleBindingMismatch,
5432 "managed session bundle launch binding is invalid",
5433 ))?)
5434 } else {
5435 None
5436 };
5437 if !has_environment_materialization {
5438 let overlay = bundle_arguments.map(|extra_args| {
5439 NativeInstanceLaunchOverlay::new(
5440 provider.clone(),
5441 TransportKind::Pty,
5442 Vec::new(),
5443 extra_args,
5444 )
5445 .map(PreparedNativeLaunchOverlay::Instance)
5446 .map_err(|_| failure(
5447 NodeFailureCode::BundleBindingMismatch,
5448 "managed session bundle launch overlay is invalid",
5449 ))
5450 }).transpose()?;
5451 return Ok(Some((ownership.id().clone(), overlay)));
5452 }
5453 let environment = match materializer.resolve_environment(&ownership, &profile) {
5454 Ok(environment) => environment,
5455 Err(
5456 SessionEnvironmentMaterializeError::SecretUnavailable
5457 | SessionEnvironmentMaterializeError::SecretDenied
5458 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5459 ) => {
5460 return Err(failure(
5461 NodeFailureCode::BackendOperationFailed,
5462 "managed session environment resolution failed",
5463 ));
5464 }
5465 Err(
5466 SessionEnvironmentMaterializeError::InvalidRoot
5467 | SessionEnvironmentMaterializeError::OwnershipMismatch
5468 | SessionEnvironmentMaterializeError::Filesystem(_)
5469 | SessionEnvironmentMaterializeError::Record(_),
5470 ) => {
5471 self.mark_materialization_recovery_required(ownership.id());
5472 return Err(failure(
5473 NodeFailureCode::BackendOperationFailed,
5474 "managed session environment resolution failed",
5475 ));
5476 }
5477 };
5478 let transport = match mode {
5479 SessionMode::Pty => TransportKind::Pty,
5480 SessionMode::Inline => TransportKind::Pipe,
5481 SessionMode::Acp => TransportKind::Acp,
5482 };
5483 let overlay = match bundle_arguments {
5484 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5485 NativeInstanceLaunchOverlay::new(
5486 provider.clone(),
5487 transport,
5488 environment,
5489 extra_args,
5490 )
5491 .map_err(|_| failure(
5492 NodeFailureCode::BundleBindingMismatch,
5493 "managed session bundle launch overlay is invalid",
5494 ))?,
5495 ),
5496 None => PreparedNativeLaunchOverlay::Environment(
5497 NativeLaunchEnvironmentOverlay::new(
5498 provider.clone(),
5499 transport,
5500 environment,
5501 )
5502 .map_err(|_| {
5503 failure(
5504 NodeFailureCode::BackendOperationFailed,
5505 "managed session environment overlay is invalid",
5506 )
5507 })?,
5508 ),
5509 };
5510 Ok(Some((ownership.id().clone(), Some(overlay))))
5511 }
5512 }
5513 }
5514
5515 fn destroy_record_materialization(
5516 &self,
5517 record_id: &SessionRecordId,
5518 ) -> Result<bool, NodeFailure> {
5519 let ids = self
5520 .materializations
5521 .lock()
5522 .unwrap_or_else(|poisoned| poisoned.into_inner())
5523 .values()
5524 .filter_map(|record| {
5525 (record.owner()
5526 == &MaterializationOwner::Record {
5527 record_id: record_id.clone(),
5528 })
5529 .then(|| record.id().clone())
5530 })
5531 .collect::<Vec<_>>();
5532 if ids.len() > 1 {
5533 return Err(failure(
5534 NodeFailureCode::BackendOperationFailed,
5535 "managed session has conflicting environment ownership",
5536 ));
5537 }
5538 let Some(id) = ids.first().cloned() else {
5539 return Ok(false);
5540 };
5541 let (terminal_record, mut cleanup_ownership) = {
5542 let _transaction = self
5543 .state_transaction
5544 .lock()
5545 .unwrap_or_else(|poisoned| poisoned.into_inner());
5546 let original_record = self.record(record_id)?;
5547 let mut terminal_record = original_record.clone();
5548 terminal_record.provider_session = None;
5549 terminal_record.active_session = None;
5550 terminal_record.state = ManagedSessionState::Unavailable;
5551 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5552 terminal_record.updated_at_unix_ms = unix_time_ms();
5553 let original_ownership = self
5554 .materializations
5555 .lock()
5556 .unwrap_or_else(|poisoned| poisoned.into_inner())
5557 .get(&id)
5558 .cloned()
5559 .ok_or_else(|| {
5560 failure(
5561 NodeFailureCode::BackendOperationFailed,
5562 "managed session environment ownership disappeared during cleanup",
5563 )
5564 })?;
5565 let mut cleanup_ownership = original_ownership.clone();
5566 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5567 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5568 failure(
5569 NodeFailureCode::BackendOperationFailed,
5570 "managed session environment cleanup state is invalid",
5571 )
5572 })?;
5573 }
5574 self.session_records
5575 .lock()
5576 .unwrap_or_else(|poisoned| poisoned.into_inner())
5577 .records
5578 .insert(record_id.clone(), terminal_record.clone());
5579 self.materializations
5580 .lock()
5581 .unwrap_or_else(|poisoned| poisoned.into_inner())
5582 .insert(id.clone(), cleanup_ownership.clone());
5583 if let Err(error) = self.persist_state_locked() {
5584 self.session_records
5585 .lock()
5586 .unwrap_or_else(|poisoned| poisoned.into_inner())
5587 .records
5588 .insert(record_id.clone(), original_record);
5589 self.materializations
5590 .lock()
5591 .unwrap_or_else(|poisoned| poisoned.into_inner())
5592 .insert(id.clone(), original_ownership);
5593 return Err(persistence_failure(error));
5594 }
5595 (terminal_record, cleanup_ownership)
5596 };
5597 let cleanup = self
5598 .session_environment_materializer
5599 .as_ref()
5600 .ok_or(())
5601 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5602 if cleanup.is_err() {
5603 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5604 self.materializations
5605 .lock()
5606 .unwrap_or_else(|poisoned| poisoned.into_inner())
5607 .insert(id.clone(), cleanup_ownership);
5608 if self.persist_state().is_err() {
5609 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5610 }
5611 return Err(failure(
5612 NodeFailureCode::BackendOperationFailed,
5613 "managed session environment cleanup requires recovery",
5614 ));
5615 }
5616 let _transaction = self
5617 .state_transaction
5618 .lock()
5619 .unwrap_or_else(|poisoned| poisoned.into_inner());
5620 self.remove_record_memory(record_id);
5621 self.materializations
5622 .lock()
5623 .unwrap_or_else(|poisoned| poisoned.into_inner())
5624 .remove(&id);
5625 if let Err(error) = self.persist_state_locked() {
5626 self.session_records
5627 .lock()
5628 .unwrap_or_else(|poisoned| poisoned.into_inner())
5629 .records
5630 .insert(record_id.clone(), terminal_record);
5631 self.materializations
5632 .lock()
5633 .unwrap_or_else(|poisoned| poisoned.into_inner())
5634 .insert(id, cleanup_ownership);
5635 return Err(persistence_failure(error));
5636 }
5637 Ok(true)
5638 }
5639
5640 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5641 let changed = {
5642 let mut materializations = self
5643 .materializations
5644 .lock()
5645 .unwrap_or_else(|poisoned| poisoned.into_inner());
5646 let Some(ownership) = materializations.get_mut(id) else {
5647 return;
5648 };
5649 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5650 };
5651 if changed && self.persist_state().is_err() {
5652 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5653 }
5654 }
5655
5656 fn reconcile_materializations(&self) {
5657 let records = self
5658 .materializations
5659 .lock()
5660 .unwrap_or_else(|poisoned| poisoned.into_inner())
5661 .values()
5662 .cloned()
5663 .collect::<Vec<_>>();
5664 for mut ownership in records {
5665 let result = match (ownership.state(), ownership.owner()) {
5666 (MaterializationState::Preparing, _)
5667 | (MaterializationState::CleanupRequired, _) => {
5668 self.cleanup_materialization(ownership.id())
5669 }
5670 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5671 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5672 self.materializations
5673 .lock()
5674 .unwrap_or_else(|poisoned| poisoned.into_inner())
5675 .insert(ownership.id().clone(), ownership.clone());
5676 self.persist_state().map_err(persistence_failure)
5677 } else {
5678 Err(failure(
5679 NodeFailureCode::BackendOperationFailed,
5680 "session environment reconciliation failed",
5681 ))
5682 }
5683 }
5684 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5685 .session_environment_materializer
5686 .as_ref()
5687 .ok_or_else(|| {
5688 failure(
5689 NodeFailureCode::BackendOperationFailed,
5690 "session environment materializer is unavailable",
5691 )
5692 })
5693 .and_then(|materializer| {
5694 materializer.revalidate(&ownership).map_err(|_| failure(
5695 NodeFailureCode::BackendOperationFailed,
5696 "session materialization revalidation failed",
5697 ))?;
5698 let record = self.record(record_id)?;
5699 if record.environment_profile.as_ref() != ownership.environment_profile()
5700 || record.bundle.as_ref() != ownership.bundle()
5701 || record.context.as_ref() != ownership.context()
5702 {
5703 return Err(failure(
5704 NodeFailureCode::BackendOperationFailed,
5705 "session materialization ownership changed after restart",
5706 ));
5707 }
5708 if let Some(receipt) = ownership.bundle() {
5709 let catalog = self.bundle_catalog.read()
5710 .unwrap_or_else(|poisoned| poisoned.into_inner());
5711 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5712 NodeFailureCode::UnknownBundle,
5713 "session bundle is unavailable after restart",
5714 ))?;
5715 if bundle.receipt() != *receipt {
5716 return Err(failure(
5717 NodeFailureCode::BundleBindingMismatch,
5718 "session bundle changed after restart",
5719 ));
5720 }
5721 let layout = self.bundle_layout(
5722 &record.provider,
5723 record.mode,
5724 record.environment_profile.as_ref(),
5725 bundle,
5726 )?;
5727 materializer
5728 .revalidate_bundle(&ownership, bundle, layout)
5729 .map_err(|_| failure(
5730 NodeFailureCode::BundleMaterializationFailed,
5731 "session bundle revalidation failed",
5732 ))?;
5733 }
5734 if let Some(receipt) = ownership.context() {
5735 let pack = materializer
5736 .revalidate_context(&ownership, receipt)
5737 .map_err(|_| {
5738 failure(
5739 NodeFailureCode::ContextPackMaterializationFailed,
5740 "session context revalidation failed",
5741 )
5742 })?;
5743 self.context_catalog
5744 .write()
5745 .unwrap_or_else(|poisoned| poisoned.into_inner())
5746 .insert(pack)
5747 .map_err(|_| {
5748 failure(
5749 NodeFailureCode::ContextPackMaterializationFailed,
5750 "session context catalog restore failed",
5751 )
5752 })?;
5753 }
5754 Ok(())
5755 }),
5756 (MaterializationState::RecoveryRequired, _) => Ok(()),
5757 };
5758 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5759 let _ = ownership.mark_recovery_required(unix_time_ms());
5760 self.materializations
5761 .lock()
5762 .unwrap_or_else(|poisoned| poisoned.into_inner())
5763 .insert(ownership.id().clone(), ownership);
5764 if self.persist_state().is_err() {
5765 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5766 }
5767 }
5768 }
5769 let materialized_profiles = self
5770 .environment_materialization_profiles
5771 .read()
5772 .unwrap_or_else(|poisoned| poisoned.into_inner())
5773 .keys()
5774 .cloned()
5775 .collect::<Vec<_>>();
5776 let ownership = self
5777 .materializations
5778 .lock()
5779 .unwrap_or_else(|poisoned| poisoned.into_inner())
5780 .values()
5781 .cloned()
5782 .collect::<Vec<_>>();
5783 let changed = {
5784 let mut session_records = self
5785 .session_records
5786 .lock()
5787 .unwrap_or_else(|poisoned| poisoned.into_inner());
5788 let mut changed = false;
5789 for record in session_records.records.values_mut() {
5790 let environment_profile = record.environment_profile.as_ref();
5791 if environment_profile.is_none()
5792 && record.bundle.is_none()
5793 && record.context.is_none()
5794 {
5795 continue;
5796 }
5797 if record.bundle.is_none()
5798 && environment_profile.is_some_and(|profile| {
5799 !materialized_profiles.contains(&profile.profile_id)
5800 })
5801 {
5802 continue;
5803 }
5804 let has_exact_ownership = ownership.iter().any(|candidate| {
5805 candidate.state() == MaterializationState::Ready
5806 && candidate.environment_profile() == environment_profile
5807 && candidate.bundle() == record.bundle.as_ref()
5808 && candidate.context() == record.context.as_ref()
5809 && candidate.owner()
5810 == &MaterializationOwner::Record {
5811 record_id: record.record_id.clone(),
5812 }
5813 });
5814 if !has_exact_ownership {
5815 record.provider_session = None;
5816 record.active_session = None;
5817 record.state = ManagedSessionState::Unavailable;
5818 record.last_error = Some(if record.bundle.is_some() {
5819 "bundle-unavailable".to_owned()
5820 } else if record.context.is_some() {
5821 "context-unavailable".to_owned()
5822 } else {
5823 "environment-profile-unavailable".to_owned()
5824 });
5825 record.updated_at_unix_ms = unix_time_ms();
5826 changed = true;
5827 }
5828 }
5829 changed
5830 };
5831 if changed && self.persist_state().is_err() {
5832 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5833 }
5834 }
5835
5836 fn replay_spawn_spec(
5837 &self,
5838 spec: &SpawnSpec,
5839 now: Instant,
5840 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5841 let mut cache = self
5842 .spawn_idempotency
5843 .lock()
5844 .unwrap_or_else(|poisoned| poisoned.into_inner());
5845 cache.entries.retain(|_, entry| entry.expires_at > now);
5846 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5847 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5848 return Err(failure(
5849 NodeFailureCode::SpawnIdempotencyConflict,
5850 "spawn idempotency key was reused across spawn kinds",
5851 ));
5852 };
5853 if existing != spec {
5854 return Err(failure(
5855 NodeFailureCode::SpawnIdempotencyConflict,
5856 "spawn idempotency key was reused with a different specification",
5857 ));
5858 }
5859 return Ok(Some(result.clone()));
5860 }
5861 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5862 return Err(failure(
5863 NodeFailureCode::SpawnIdempotencyCapacity,
5864 "spawn idempotency cache reached its bounded capacity",
5865 ));
5866 }
5867 Ok(None)
5868 }
5869
5870 fn remember_spawn_spec(
5871 &self,
5872 spec: SpawnSpec,
5873 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5874 accepted_at: Instant,
5875 ) {
5876 self
5877 .spawn_idempotency
5878 .lock()
5879 .unwrap_or_else(|poisoned| poisoned.into_inner())
5880 .entries
5881 .insert(
5882 spec.idempotency_key.clone(),
5883 SpawnIdempotencyEntry {
5884 value: SpawnIdempotencyValue::Standard { spec, result },
5885 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5886 },
5887 );
5888 }
5889
5890 fn claim_harness_mcp_spawn(
5891 &self,
5892 _reservation_id: &HarnessMcpReservationId,
5893 _activation_digest: &HarnessMcpActivationDigest,
5894 spec: &SpawnSpec,
5895 now: Instant,
5896 ) -> Result<(), NodeFailure> {
5897 let mut cache = self.spawn_idempotency.lock()
5898 .unwrap_or_else(|poisoned| poisoned.into_inner());
5899 cache.entries.retain(|_, entry| entry.expires_at > now);
5900 if cache.entries.contains_key(&spec.idempotency_key) {
5901 return Err(failure(
5902 NodeFailureCode::SpawnIdempotencyConflict,
5903 "spawn idempotency key was reused across spawn kinds",
5904 ));
5905 }
5906 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5907 return Err(failure(
5908 NodeFailureCode::SpawnIdempotencyCapacity,
5909 "spawn idempotency cache reached its bounded capacity",
5910 ));
5911 }
5912 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5913 value: SpawnIdempotencyValue::HarnessMcp,
5914 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5915 });
5916 Ok(())
5917 }
5918
5919 fn replay_managed_spawn(
5920 &self,
5921 request: &ManagedWorktreeSpawnRequest,
5922 now: Instant,
5923 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
5924 let mut cache = self.spawn_idempotency.lock()
5925 .unwrap_or_else(|poisoned| poisoned.into_inner());
5926 cache.entries.retain(|_, entry| entry.expires_at > now);
5927 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
5928 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
5929 return Err(failure(
5930 NodeFailureCode::SpawnIdempotencyConflict,
5931 "spawn idempotency key was reused across spawn kinds",
5932 ));
5933 };
5934 if existing != request {
5935 return Err(failure(
5936 NodeFailureCode::SpawnIdempotencyConflict,
5937 "spawn idempotency key was reused with a different managed specification",
5938 ));
5939 }
5940 return Ok(Some(result.clone()));
5941 }
5942 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5943 return Err(failure(
5944 NodeFailureCode::SpawnIdempotencyCapacity,
5945 "spawn idempotency cache reached its bounded capacity",
5946 ));
5947 }
5948 Ok(None)
5949 }
5950
5951 fn remember_managed_spawn(
5952 &self,
5953 request: ManagedWorktreeSpawnRequest,
5954 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5955 accepted_at: Instant,
5956 ) {
5957 self.spawn_idempotency.lock()
5958 .unwrap_or_else(|poisoned| poisoned.into_inner())
5959 .entries
5960 .insert(
5961 request.spawn_spec.idempotency_key.clone(),
5962 SpawnIdempotencyEntry {
5963 value: SpawnIdempotencyValue::Managed { request, result },
5964 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5965 },
5966 );
5967 }
5968
5969 fn remember_managed_spawn_attempt(
5970 &self,
5971 durable_v2_key: Option<&SpawnIdempotencyKey>,
5972 request: ManagedWorktreeSpawnRequest,
5973 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
5974 accepted_at: Instant,
5975 ) {
5976 if durable_v2_key.is_none() {
5977 self.remember_managed_spawn(request, result, accepted_at);
5978 }
5979 }
5980
5981 fn acquire_durable_managed_spawn_v2(
5982 &self,
5983 request: &ManagedWorktreeSpawnRequestV2,
5984 request_digest: String,
5985 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
5986 if self.state_path.is_none() {
5987 return Err(failure(
5988 NodeFailureCode::BackendOperationFailed,
5989 "managed worktree V2 requires durable node state",
5990 ));
5991 }
5992 let _transaction = self.state_transaction
5993 .lock()
5994 .unwrap_or_else(|poisoned| poisoned.into_inner());
5995 let key = request.spawn_spec.idempotency_key.clone();
5996 let mut previous = None;
5997 {
5998 let mut replays = self.managed_spawn_replays
5999 .lock()
6000 .unwrap_or_else(|poisoned| poisoned.into_inner());
6001 if let Some(record) = replays.get_mut(&key) {
6002 if record.request_digest != request_digest {
6003 return Err(failure(
6004 NodeFailureCode::SpawnIdempotencyConflict,
6005 "managed worktree V2 idempotency key was reused with a different request",
6006 ));
6007 }
6008 match &record.state {
6009 ManagedWorktreeSpawnReplayStateV10::Pending {
6010 owner_incarnation_id,
6011 lease_id: None,
6012 } if owner_incarnation_id != &self.incarnation_id => {
6013 previous = Some(record.clone());
6014 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6015 owner_incarnation_id: self.incarnation_id,
6016 lease_id: None,
6017 };
6018 record.updated_at_unix_ms = unix_time_ms();
6019 }
6020 ManagedWorktreeSpawnReplayStateV10::Pending {
6021 lease_id: None,
6022 ..
6023 } => {
6024 return Err(failure(
6025 NodeFailureCode::BackendBusy,
6026 "managed worktree V2 reservation is already active",
6027 ));
6028 }
6029 ManagedWorktreeSpawnReplayStateV10::Pending {
6030 lease_id: Some(_),
6031 ..
6032 } => {
6033 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6034 }
6035 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6036 return Ok(DurableManagedSpawnReplayDecision::Committed(
6037 receipt.clone(),
6038 ));
6039 }
6040 }
6041 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6042 return Err(failure(
6043 NodeFailureCode::SpawnIdempotencyCapacity,
6044 "durable managed worktree replay ledger reached its bounded capacity",
6045 ));
6046 } else {
6047 let now = unix_time_ms();
6048 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6049 idempotency_key: key.clone(),
6050 request_digest,
6051 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6052 profile_id: request.worktree_profile_id.clone(),
6053 expected_profile_revision: request.expected_profile_revision.clone(),
6054 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6055 owner_incarnation_id: self.incarnation_id,
6056 lease_id: None,
6057 },
6058 created_at_unix_ms: now,
6059 updated_at_unix_ms: now,
6060 });
6061 }
6062 }
6063 if let Err(error) = self.persist_state_locked() {
6064 let mut replays = self.managed_spawn_replays
6065 .lock()
6066 .unwrap_or_else(|poisoned| poisoned.into_inner());
6067 match previous {
6068 Some(previous) => {
6069 replays.insert(key, previous);
6070 }
6071 None => {
6072 replays.remove(&key);
6073 }
6074 }
6075 return Err(persistence_failure(error));
6076 }
6077 Ok(DurableManagedSpawnReplayDecision::Reserved)
6078 }
6079
6080 fn link_durable_managed_spawn_v2_lease_locked(
6081 &self,
6082 key: &SpawnIdempotencyKey,
6083 lease_id: &ManagedWorktreeLeaseId,
6084 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6085 let mut replays = self.managed_spawn_replays
6086 .lock()
6087 .unwrap_or_else(|poisoned| poisoned.into_inner());
6088 let record = replays.get_mut(key).ok_or_else(|| {
6089 failure(
6090 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6091 "managed worktree V2 reservation disappeared before allocation",
6092 )
6093 })?;
6094 let previous = record.clone();
6095 match &mut record.state {
6096 ManagedWorktreeSpawnReplayStateV10::Pending {
6097 lease_id: current,
6098 ..
6099 }
6100 if current.is_none() =>
6101 {
6102 *current = Some(lease_id.clone());
6103 record.updated_at_unix_ms = unix_time_ms();
6104 Ok(previous)
6105 }
6106 _ => Err(failure(
6107 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6108 "managed worktree V2 reservation is not allocation-ready",
6109 )),
6110 }
6111 }
6112
6113 fn restore_durable_managed_spawn_v2_locked(
6114 &self,
6115 key: &SpawnIdempotencyKey,
6116 previous: ManagedWorktreeSpawnReplayRecordV10,
6117 ) {
6118 self.managed_spawn_replays
6119 .lock()
6120 .unwrap_or_else(|poisoned| poisoned.into_inner())
6121 .insert(key.clone(), previous);
6122 }
6123
6124 fn commit_durable_managed_spawn_v2(
6125 &self,
6126 request: &ManagedWorktreeSpawnRequestV2,
6127 receipt: ManagedWorktreeSpawnReceipt,
6128 ) -> Result<(), NodeFailure> {
6129 let _transaction = self.state_transaction
6130 .lock()
6131 .unwrap_or_else(|poisoned| poisoned.into_inner());
6132 let key = &request.spawn_spec.idempotency_key;
6133 let previous = {
6134 let mut replays = self.managed_spawn_replays
6135 .lock()
6136 .unwrap_or_else(|poisoned| poisoned.into_inner());
6137 let record = replays.get_mut(key).ok_or_else(|| {
6138 failure(
6139 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6140 "managed worktree V2 reservation disappeared before commit",
6141 )
6142 })?;
6143 let previous = record.clone();
6144 match &record.state {
6145 ManagedWorktreeSpawnReplayStateV10::Pending {
6146 lease_id: Some(lease_id),
6147 ..
6148 } if lease_id == &receipt.lease.lease_id => {}
6149 _ => {
6150 return Err(failure(
6151 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6152 "managed worktree V2 reservation is not linked to the spawned lease",
6153 ));
6154 }
6155 }
6156 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6157 record.updated_at_unix_ms = unix_time_ms();
6158 previous
6159 };
6160 if let Err(error) = self.persist_state_locked() {
6161 self.restore_durable_managed_spawn_v2_locked(key, previous);
6162 return Err(persistence_failure(error));
6163 }
6164 Ok(())
6165 }
6166
6167 fn remove_unallocated_durable_managed_spawn_v2(
6168 &self,
6169 request: &ManagedWorktreeSpawnRequestV2,
6170 ) -> Result<(), NodeFailure> {
6171 let _transaction = self.state_transaction
6172 .lock()
6173 .unwrap_or_else(|poisoned| poisoned.into_inner());
6174 let key = &request.spawn_spec.idempotency_key;
6175 let removed = {
6176 let mut replays = self.managed_spawn_replays
6177 .lock()
6178 .unwrap_or_else(|poisoned| poisoned.into_inner());
6179 match replays.get(key).map(|record| &record.state) {
6180 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6181 lease_id: None,
6182 ..
6183 }) => {
6184 replays.remove(key)
6185 }
6186 _ => None,
6187 }
6188 };
6189 let Some(removed) = removed else {
6190 return Ok(());
6191 };
6192 if let Err(error) = self.persist_state_locked() {
6193 self.managed_spawn_replays
6194 .lock()
6195 .unwrap_or_else(|poisoned| poisoned.into_inner())
6196 .insert(key.clone(), removed);
6197 return Err(persistence_failure(error));
6198 }
6199 Ok(())
6200 }
6201
6202 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6203 if spec.target.node_id != self.node_id {
6204 return Err(failure(
6205 NodeFailureCode::SpawnTargetMismatch,
6206 "spawn target does not match this node",
6207 ));
6208 }
6209 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6210 failure(
6211 NodeFailureCode::UnknownSpawnProfile,
6212 "spawn profile is unavailable on this node",
6213 )
6214 })?;
6215 let resolved = spec.resolve(defaults).map_err(|error| match error {
6216 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6217 NodeFailureCode::SpawnProfileRevisionMismatch,
6218 "spawn profile revision does not match the loaded profile",
6219 ),
6220 _ => failure(
6221 NodeFailureCode::InvalidRequest,
6222 "spawn specification could not be resolved",
6223 ),
6224 })?;
6225 if resolved.target.worktree_id.is_some() {
6226 self.require_worktree_service(&resolved.target.workspace_id)?;
6227 }
6228 let environment_profile = self.resolve_environment_profile(&resolved)?;
6229 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6230 self.resolve_context(&resolved)?;
6231 Ok(resolved)
6232 }
6233
6234 async fn resolve_spawn_workspace(
6235 &self,
6236 resolved: &ResolvedSpawnSpec,
6237 deadline: Instant,
6238 ) -> Result<WorkspaceId, NodeFailure> {
6239 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6240 return Ok(resolved.target.workspace_id.clone());
6241 };
6242 if self.managed_worktrees.lock()
6243 .unwrap_or_else(|poisoned| poisoned.into_inner())
6244 .lease_for_workspace(worktree_id)
6245 .is_some()
6246 {
6247 return Err(failure(
6248 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6249 "managed worktree targets may only be spawned through their lease request",
6250 ));
6251 }
6252 self.require_worktree_service(&resolved.target.workspace_id)?;
6253 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6254 let selected_root = self.workspace_root(worktree_id)?;
6255 let remaining = spawn_deadline_remaining(deadline)?;
6256 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6257 .await
6258 .map_err(|_| {
6259 failure(
6260 NodeFailureCode::SpawnDeadlineExceeded,
6261 "Git worktree selection exceeded the spawn deadline",
6262 )
6263 })?
6264 .map_err(git_worktree_failure)?;
6265 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6266 Ok(worktree_id.clone())
6267 }
6268
6269 async fn spawn_from_spec(
6270 &self,
6271 spec: SpawnSpec,
6272 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6273 self.spawn_from_spec_with_proxy(spec, None).await
6274 }
6275
6276 async fn spawn_from_spec_with_proxy(
6277 &self,
6278 spec: SpawnSpec,
6279 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6280 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6281 let accepted_at = Instant::now();
6282 if harness_mcp.is_none() {
6283 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6284 return replayed;
6285 }
6286 }
6287 let resolved = self.resolve_spawn_spec(&spec)?;
6288 let environment_profile = self.resolve_environment_profile(&resolved)?;
6289 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6290 let context = self.resolve_context(&resolved)?;
6291 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6292 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6293 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6294 let spawn_workspace_id = match spawn_workspace_id {
6295 Ok(workspace_id) => workspace_id,
6296 Err(error) => {
6297 let result = Err(error);
6298 if harness_mcp.is_none() {
6299 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6300 }
6301 return result;
6302 }
6303 };
6304 if self.managed_worktrees.lock()
6305 .unwrap_or_else(|poisoned| poisoned.into_inner())
6306 .lease_for_workspace(&spawn_workspace_id)
6307 .is_some()
6308 {
6309 let result = Err(failure(
6310 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6311 "managed worktree targets may only be spawned through their lease request",
6312 ));
6313 if harness_mcp.is_none() {
6314 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6315 }
6316 return result;
6317 }
6318 let result = self
6319 .spawn_session_with_deadline(
6320 spawn_workspace_id,
6321 resolved.provider.clone(),
6322 resolved.mode,
6323 resolved.terminal_size,
6324 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6325 environment_profile.clone(),
6326 bundle.clone(),
6327 context.clone(),
6328 None,
6329 None,
6330 SpawnRecordPolicy::Always,
6331 Some(deadline),
6332 &required_capabilities,
6333 harness_mcp,
6334 resolved.approval_level,
6335 resolved.network_allowlist.clone(),
6336 resolved.browser_profile_id.clone(),
6337 )
6338 .await
6339 .map(|(session, _runtime_policy)| {
6340 resolved.receipt_with_materialization(
6341 self.incarnation_id,
6342 session,
6343 environment_profile,
6344 bundle,
6345 context,
6346 )
6347 });
6348 if harness_mcp.is_none() {
6349 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6350 }
6351 result
6352 }
6353
6354 async fn spawn_from_spec_with_harness_mcp(
6355 &self,
6356 reservation_id: HarnessMcpReservationId,
6357 activation_digest: HarnessMcpActivationDigest,
6358 spec: SpawnSpec,
6359 deadline_unix_ms: u64,
6360 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6361 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6362 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6363 })?;
6364 if let Some(receipt) = registry
6365 .replay_spawn(&reservation_id, &activation_digest, &spec)
6366 .map_err(harness_mcp_failure)?
6367 {
6368 return Ok(receipt);
6369 }
6370 let resolved = self.resolve_spawn_spec(&spec)?;
6371 let prepared = registry
6372 .prepare_spawn(
6373 &reservation_id,
6374 &activation_digest,
6375 &spec,
6376 deadline_unix_ms,
6377 resolved.provider.clone(),
6378 )
6379 .map_err(harness_mcp_failure)?;
6380 self.claim_harness_mcp_spawn(
6381 &reservation_id,
6382 &activation_digest,
6383 &spec,
6384 Instant::now(),
6385 )?;
6386 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6387 let mut receipt = match spawn {
6388 Ok(receipt) => receipt,
6389 Err(error) => {
6390 let _ = registry.abort(&reservation_id, &activation_digest);
6391 return Err(error);
6392 }
6393 };
6394 let record_id = self
6395 .session_bindings
6396 .lock()
6397 .unwrap_or_else(|poisoned| poisoned.into_inner())
6398 .get(&receipt.session.session.instance_id)
6399 .and_then(|binding| binding.record_id.clone());
6400 let record_id = match record_id {
6401 Some(record_id) => record_id,
6402 None => {
6403 let rollback = self.remove_session(&receipt.session).await;
6404 let _ = registry.abort(&reservation_id, &activation_digest);
6405 if let Err(rollback_error) = rollback {
6406 return Err(rollback_error);
6407 }
6408 return Err(failure(
6409 NodeFailureCode::BindingMismatch,
6410 "harness MCP spawn did not retain its exact managed record",
6411 ));
6412 }
6413 };
6414 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6415 reservation_id: reservation_id.clone(),
6416 activation_digest: activation_digest.clone(),
6417 });
6418 if let Err(error) = registry.mark_spawned(
6419 &prepared,
6420 receipt.session.clone(),
6421 record_id,
6422 receipt.clone(),
6423 ) {
6424 let rollback = self.remove_session(&receipt.session).await;
6425 let _ = registry.abort(&reservation_id, &activation_digest);
6426 if let Err(rollback_error) = rollback {
6427 return Err(rollback_error);
6428 }
6429 return Err(harness_mcp_failure(error));
6430 }
6431 Ok(receipt)
6432 }
6433
6434 async fn spawn_managed_worktree(
6435 &self,
6436 request: ManagedWorktreeSpawnRequest,
6437 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6438 self.spawn_managed_worktree_inner(request, None).await
6439 }
6440
6441 async fn spawn_managed_worktree_inner(
6442 &self,
6443 request: ManagedWorktreeSpawnRequest,
6444 durable_v2_key: Option<&SpawnIdempotencyKey>,
6445 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6446 let accepted_at = Instant::now();
6447 if durable_v2_key.is_none() {
6448 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6449 return replayed;
6450 }
6451 }
6452 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6453 if request.spawn_spec.target.worktree_id.is_some() {
6454 let result = Err(failure(
6455 NodeFailureCode::InvalidRequest,
6456 "managed worktree spawn must not select a caller-provided worktree",
6457 ));
6458 self.remember_managed_spawn_attempt(
6459 durable_v2_key,
6460 request,
6461 result.clone(),
6462 accepted_at,
6463 );
6464 return result;
6465 }
6466 let environment_profile = self.resolve_environment_profile(&resolved)?;
6467 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6468 let context = self.resolve_context(&resolved)?;
6469 let source_workspace_id = resolved.target.workspace_id.clone();
6470 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6471 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6472 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6473 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6474 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6475 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6476 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6477 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6478 };
6479 let runtime_policy = timeout(
6480 spawn_deadline_remaining(deadline)?,
6481 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6482 ).await.map_err(|_| failure(
6483 NodeFailureCode::SpawnDeadlineExceeded,
6484 "provider admission exceeded the managed spawn deadline",
6485 ))??;
6486 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6487 return Err(failure(
6488 NodeFailureCode::UnsupportedSpawnCapability,
6489 "provider runtime does not admit a required spawn capability",
6490 ));
6491 }
6492 self.ensure_binding_capacity()?;
6493 let source_root = self.workspace_root(&source_workspace_id)?;
6494 profile.validate_for_workspace(&source_root)
6495 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6496 let base_timeout_ms = u64::try_from(
6497 spawn_deadline_remaining(deadline)?.as_millis(),
6498 ).unwrap_or(u64::MAX).max(1);
6499 let base_commit = resolve_base_commit_with_timeout(
6500 &source_root,
6501 profile.base(),
6502 base_timeout_ms,
6503 ).await.map_err(|error| {
6504 if Instant::now() >= deadline || error.message.contains("timed out") {
6505 failure(
6506 NodeFailureCode::SpawnDeadlineExceeded,
6507 "managed worktree base resolution exceeded the spawn deadline",
6508 )
6509 } else {
6510 managed_git_worktree_failure(error)
6511 }
6512 })?;
6513
6514 let lease = {
6515 let _transaction = self.state_transaction.lock()
6516 .unwrap_or_else(|poisoned| poisoned.into_inner());
6517 let previous = self.managed_worktrees.lock()
6518 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6519 let lease = self.managed_worktrees.lock()
6520 .unwrap_or_else(|poisoned| poisoned.into_inner())
6521 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6522 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6523 let previous_replay = match durable_v2_key
6524 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6525 .transpose()
6526 {
6527 Ok(previous) => previous,
6528 Err(error) => {
6529 *self.managed_worktrees.lock()
6530 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6531 return Err(error);
6532 }
6533 };
6534 if let Err(error) = self.persist_state_locked() {
6535 *self.managed_worktrees.lock()
6536 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6537 if let (Some(key), Some(previous_replay)) =
6538 (durable_v2_key, previous_replay)
6539 {
6540 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6541 }
6542 return Err(persistence_failure(error));
6543 }
6544 lease
6545 };
6546 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6547 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6548 self.managed_worktrees.lock()
6549 .unwrap_or_else(|poisoned| poisoned.into_inner())
6550 .remove_unmutated(&lease.lease_id);
6551 if self.persist_state().is_err() {
6552 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6553 }
6554 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6555 self.remember_managed_spawn_attempt(
6556 durable_v2_key,
6557 request,
6558 result.clone(),
6559 accepted_at,
6560 );
6561 return result;
6562 }
6563 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6564 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6565 Err(error) => {
6566 self.reconcile_failed_allocation(&lease, &source_root).await;
6567 let result = Err(error);
6568 self.remember_managed_spawn_attempt(
6569 durable_v2_key,
6570 request,
6571 result.clone(),
6572 accepted_at,
6573 );
6574 return result;
6575 }
6576 };
6577 let created = match create_git_worktree_with_timeout(
6578 &source_root,
6579 &lease.target_root,
6580 &lease.branch,
6581 Some(&base_commit),
6582 mutation_timeout_ms,
6583 ).await {
6584 Ok(created) => created,
6585 Err(error) => {
6586 self.reconcile_failed_allocation(&lease, &source_root).await;
6587 let result = Err(if Instant::now() >= deadline
6588 || error.message.contains("timed out")
6589 || error.message.contains("deadline")
6590 {
6591 failure(
6592 NodeFailureCode::SpawnDeadlineExceeded,
6593 "managed worktree creation exceeded the spawn deadline",
6594 )
6595 } else {
6596 managed_git_worktree_failure(error)
6597 });
6598 self.remember_managed_spawn_attempt(
6599 durable_v2_key,
6600 request,
6601 result.clone(),
6602 accepted_at,
6603 );
6604 return result;
6605 }
6606 };
6607 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6608 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6609 let result = Err(failure(
6610 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6611 "created worktree identity did not match its durable lease",
6612 ));
6613 self.remember_managed_spawn_attempt(
6614 durable_v2_key,
6615 request,
6616 result.clone(),
6617 accepted_at,
6618 );
6619 return result;
6620 }
6621 {
6622 let mut registry = self.managed_worktrees.lock()
6623 .unwrap_or_else(|poisoned| poisoned.into_inner());
6624 let current = registry.get_mut(&lease.lease_id)
6625 .expect("allocated managed worktree lease remains present");
6626 current.expected_head = Some(created.head.clone());
6627 current.state = ManagedWorktreeLeaseState::Ready;
6628 current.updated_at_unix_ms = unix_time_ms();
6629 }
6630 if let Err(error) = self.persist_state() {
6631 self.mark_managed_recovery_required(
6632 &lease.lease_id,
6633 ManagedWorktreeCleanupFailure::Backend,
6634 );
6635 let result = Err(persistence_failure(error));
6636 self.remember_managed_spawn_attempt(
6637 durable_v2_key,
6638 request,
6639 result.clone(),
6640 accepted_at,
6641 );
6642 return result;
6643 }
6644 let ready = self.managed_worktrees.lock()
6645 .unwrap_or_else(|poisoned| poisoned.into_inner())
6646 .get(&lease.lease_id)
6647 .expect("ready managed worktree lease remains present")
6648 .snapshot();
6649 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6650 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6651 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6652 let result = Err(error);
6653 self.remember_managed_spawn_attempt(
6654 durable_v2_key,
6655 request,
6656 result.clone(),
6657 accepted_at,
6658 );
6659 return result;
6660 }
6661 let spawn = self.spawn_session_with_deadline(
6662 lease.workspace_id.clone(),
6663 resolved.provider.clone(),
6664 resolved.mode,
6665 resolved.terminal_size,
6666 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6667 environment_profile.clone(),
6668 bundle.clone(),
6669 context.clone(),
6670 Some(lease.lease_id.clone()),
6671 Some(runtime_policy),
6672 SpawnRecordPolicy::Always,
6673 Some(deadline),
6674 &required_capabilities,
6675 None,
6676 resolved.approval_level,
6677 resolved.network_allowlist.clone(),
6678 resolved.browser_profile_id.clone(),
6679 ).await;
6680 let (session, effective_runtime_policy) = match spawn {
6681 Ok(spawned) => spawned,
6682 Err(error) => {
6683 if self
6684 .cleanup_managed_worktree(&lease.lease_id, true)
6685 .await
6686 .is_err()
6687 {
6688 self.mark_managed_recovery_required(
6689 &lease.lease_id,
6690 ManagedWorktreeCleanupFailure::Backend,
6691 );
6692 }
6693 let result = Err(error);
6694 self.remember_managed_spawn_attempt(
6695 durable_v2_key,
6696 request,
6697 result.clone(),
6698 accepted_at,
6699 );
6700 return result;
6701 }
6702 };
6703 let (record_id, raw_inventory_record_id) = {
6704 let mut bindings = self.session_bindings.lock()
6705 .unwrap_or_else(|poisoned| poisoned.into_inner());
6706 let binding = bindings.get_mut(&session.session.instance_id)
6707 .expect("successful spawn retains its session binding");
6708 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6709 managed_spawn_record_ownership(binding, effective_runtime_policy)
6710 };
6711 let bound = self.managed_worktrees.lock()
6712 .unwrap_or_else(|poisoned| poisoned.into_inner())
6713 .bind_session(
6714 &lease.lease_id,
6715 ManagedWorktreeSessionHolder {
6716 incarnation_id: self.incarnation_id,
6717 instance_id: session.session.instance_id,
6718 generation: session.session.generation,
6719 },
6720 record_id,
6721 unix_time_ms(),
6722 )
6723 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6724 let snapshot = match bound {
6725 Ok(snapshot) => snapshot,
6726 Err(error) => {
6727 if raw_inventory_record_id
6728 .as_ref()
6729 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6730 {
6731 self.mark_managed_recovery_required(
6732 &lease.lease_id,
6733 ManagedWorktreeCleanupFailure::Backend,
6734 );
6735 }
6736 let _ = self.remove_session(&session).await;
6737 self.mark_managed_recovery_required(
6738 &lease.lease_id,
6739 ManagedWorktreeCleanupFailure::Backend,
6740 );
6741 let result = Err(error);
6742 self.remember_managed_spawn_attempt(
6743 durable_v2_key,
6744 request,
6745 result.clone(),
6746 accepted_at,
6747 );
6748 return result;
6749 }
6750 };
6751 if let Err(error) = self.persist_state() {
6752 if raw_inventory_record_id
6753 .as_ref()
6754 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6755 {
6756 self.mark_managed_recovery_required(
6757 &lease.lease_id,
6758 ManagedWorktreeCleanupFailure::Backend,
6759 );
6760 }
6761 let _ = self.remove_session(&session).await;
6762 if self
6763 .cleanup_managed_worktree(&lease.lease_id, true)
6764 .await
6765 .is_err()
6766 {
6767 self.mark_managed_recovery_required(
6768 &lease.lease_id,
6769 ManagedWorktreeCleanupFailure::Backend,
6770 );
6771 }
6772 let result = Err(persistence_failure(error));
6773 self.remember_managed_spawn_attempt(
6774 durable_v2_key,
6775 request,
6776 result.clone(),
6777 accepted_at,
6778 );
6779 return result;
6780 }
6781 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6782 let receipt = managed_spawn_receipt(
6783 &resolved,
6784 self.incarnation_id,
6785 session,
6786 snapshot,
6787 environment_profile,
6788 bundle,
6789 context,
6790 );
6791 let result = Ok(receipt);
6792 self.remember_managed_spawn_attempt(
6793 durable_v2_key,
6794 request,
6795 result.clone(),
6796 accepted_at,
6797 );
6798 result
6799 }
6800
6801 async fn spawn_managed_worktree_v2(
6802 &self,
6803 request: ManagedWorktreeSpawnRequestV2,
6804 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6805 if request.spawn_spec.target.node_id != self.node_id {
6806 return Err(failure(
6807 NodeFailureCode::SpawnTargetMismatch,
6808 "spawn target does not match this node",
6809 ));
6810 }
6811 if request.spawn_spec.target.worktree_id.is_some() {
6812 return Err(failure(
6813 NodeFailureCode::InvalidRequest,
6814 "managed worktree spawn must not select a caller-provided worktree",
6815 ));
6816 }
6817 let request_digest = managed_spawn_request_digest_v2(&request)?;
6818 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6819 match replay {
6820 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6821 DurableManagedSpawnReplayDecision::PendingLinked => {
6822 return Err(failure(
6823 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6824 "managed worktree V2 reservation has an incomplete durable allocation",
6825 ));
6826 }
6827 DurableManagedSpawnReplayDecision::Reserved => {}
6828 }
6829 let validated = (|| {
6830 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6831 let profile = self.managed_profile(
6832 &resolved.target.workspace_id,
6833 &request.worktree_profile_id,
6834 )?;
6835 if profile.revision() != &request.expected_profile_revision {
6836 return Err(failure(
6837 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6838 "managed worktree profile revision does not match the loaded profile",
6839 ));
6840 }
6841 Ok(())
6842 })();
6843 match validated {
6844 Ok(()) => {}
6845 Err(error) => {
6846 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6847 return Err(error);
6848 }
6849 }
6850 let legacy = ManagedWorktreeSpawnRequest {
6851 spawn_spec: request.spawn_spec.clone(),
6852 worktree_profile_id: request.worktree_profile_id.clone(),
6853 };
6854 match self
6855 .spawn_managed_worktree_inner(
6856 legacy,
6857 Some(&request.spawn_spec.idempotency_key),
6858 )
6859 .await
6860 {
6861 Ok(receipt) => {
6862 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6863 Ok(receipt)
6864 }
6865 Err(error) => {
6866 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6867 Err(error)
6868 }
6869 }
6870 }
6871
6872 fn managed_profile(
6873 &self,
6874 workspace_id: &WorkspaceId,
6875 profile_id: &WorktreeProfileId,
6876 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6877 if self.worktree_service_modes.get(workspace_id).copied()
6878 != Some(WorktreeServiceMode::Managed)
6879 {
6880 return Err(failure(
6881 NodeFailureCode::UnsupportedSpawnCapability,
6882 "workspace does not enable managed worktrees",
6883 ));
6884 }
6885 self.managed_worktree_profiles.get(workspace_id)
6886 .and_then(|profiles| profiles.get(profile_id))
6887 .cloned()
6888 .ok_or_else(|| failure(
6889 NodeFailureCode::InvalidRequest,
6890 "managed worktree profile is unavailable for this workspace",
6891 ))
6892 }
6893
6894 async fn reconcile_failed_allocation(
6895 &self,
6896 lease: &ManagedWorktreeLeaseRecord,
6897 source_root: &str,
6898 ) {
6899 match list_git_worktrees(source_root).await {
6900 Ok(worktrees) => {
6901 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6902 None => {
6903 match std::fs::symlink_metadata(&lease.target_root) {
6904 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6905 self.managed_worktrees.lock()
6906 .unwrap_or_else(|poisoned| poisoned.into_inner())
6907 .remove_unmutated(&lease.lease_id);
6908 let _ = self.persist_state();
6909 }
6910 _ => self.mark_managed_recovery_required(
6911 &lease.lease_id,
6912 ManagedWorktreeCleanupFailure::OwnershipConflict,
6913 ),
6914 }
6915 }
6916 Some(item) if exact_owned_worktree(
6917 lease,
6918 &item.path,
6919 item.branch.as_deref(),
6920 &item.head,
6921 ) => {
6922 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6923 }
6924 Some(_) => self.mark_managed_recovery_required(
6925 &lease.lease_id,
6926 ManagedWorktreeCleanupFailure::OwnershipConflict,
6927 ),
6928 }
6929 }
6930 Err(_) => self.mark_managed_recovery_required(
6931 &lease.lease_id,
6932 ManagedWorktreeCleanupFailure::Backend,
6933 ),
6934 }
6935 }
6936
6937 async fn reconcile_managed_worktrees(&self) {
6938 let leases = self.managed_worktrees.lock()
6939 .unwrap_or_else(|poisoned| poisoned.into_inner())
6940 .active_records()
6941 .cloned()
6942 .collect::<Vec<_>>();
6943 for lease in leases {
6944 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
6945 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
6946 _ => {
6947 self.mark_managed_recovery_required(
6948 &lease.lease_id,
6949 ManagedWorktreeCleanupFailure::OwnershipConflict,
6950 );
6951 continue;
6952 }
6953 };
6954 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
6955 self.mark_managed_recovery_required(
6956 &lease.lease_id,
6957 ManagedWorktreeCleanupFailure::OwnershipConflict,
6958 );
6959 continue;
6960 }
6961 let source_root = match self.workspace_root(&lease.source_workspace_id) {
6962 Ok(root) => root,
6963 Err(_) => {
6964 self.mark_managed_recovery_required(
6965 &lease.lease_id,
6966 ManagedWorktreeCleanupFailure::OwnershipConflict,
6967 );
6968 continue;
6969 }
6970 };
6971 let worktrees = match list_git_worktrees(&source_root).await {
6972 Ok(worktrees) => worktrees,
6973 Err(_) => {
6974 self.mark_managed_recovery_required(
6975 &lease.lease_id,
6976 ManagedWorktreeCleanupFailure::Backend,
6977 );
6978 continue;
6979 }
6980 };
6981 let listed = worktrees.iter()
6982 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
6983 let Some(listed) = listed else {
6984 match std::fs::symlink_metadata(&lease.target_root) {
6985 Err(error) if error.kind() == io::ErrorKind::NotFound
6986 && lease.state == ManagedWorktreeLeaseState::Allocating
6987 && !lease.has_holders() => {
6988 let removed = self.managed_worktrees.lock()
6989 .unwrap_or_else(|poisoned| poisoned.into_inner())
6990 .tombstone(&lease.lease_id, unix_time_ms());
6991 if self.persist_state().is_ok() && removed.is_some() {
6992 self.publish(NodeEvent::ManagedWorktreeRemoved {
6993 lease_id: lease.lease_id.clone(),
6994 });
6995 }
6996 }
6997 _ => self.mark_managed_recovery_required(
6998 &lease.lease_id,
6999 ManagedWorktreeCleanupFailure::OwnershipConflict,
7000 ),
7001 }
7002 continue;
7003 };
7004 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7005 &lease,
7006 &listed.path,
7007 listed.branch.as_deref(),
7008 &listed.head,
7009 ) {
7010 self.mark_managed_recovery_required(
7011 &lease.lease_id,
7012 ManagedWorktreeCleanupFailure::OwnershipConflict,
7013 );
7014 continue;
7015 }
7016 if !self.workspaces.read()
7017 .unwrap_or_else(|poisoned| poisoned.into_inner())
7018 .contains_key(&lease.workspace_id)
7019 {
7020 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7021 self.mark_managed_recovery_required(
7022 &lease.lease_id,
7023 ManagedWorktreeCleanupFailure::Backend,
7024 );
7025 continue;
7026 }
7027 }
7028 let snapshot = {
7029 let mut registry = self.managed_worktrees.lock()
7030 .unwrap_or_else(|poisoned| poisoned.into_inner());
7031 let current = registry.get_mut(&lease.lease_id)
7032 .expect("reconciled managed lease remains present");
7033 current.expected_head = Some(listed.head.clone());
7034 current.cleanup_failure = None;
7035 current.state = if current.has_holders() {
7036 ManagedWorktreeLeaseState::InUse
7037 } else if current.retention == ManagedWorktreeRetention::Retain {
7038 ManagedWorktreeLeaseState::Retained
7039 } else {
7040 ManagedWorktreeLeaseState::Ready
7041 };
7042 current.updated_at_unix_ms = unix_time_ms();
7043 current.snapshot()
7044 };
7045 if self.persist_state().is_err() {
7046 self.mark_managed_recovery_required(
7047 &lease.lease_id,
7048 ManagedWorktreeCleanupFailure::Backend,
7049 );
7050 continue;
7051 }
7052 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7053 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7054 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7055 }
7056 }
7057 }
7058
7059 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7079 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7080 .session_records
7081 .lock()
7082 .unwrap_or_else(|poisoned| poisoned.into_inner())
7083 .records
7084 .values()
7085 .filter(|record| {
7086 record.state == ManagedSessionState::Live
7087 && record.exported_context.is_none()
7088 && record.provider_session.is_some()
7089 })
7090 .filter_map(|record| {
7091 record
7092 .active_session
7093 .clone()
7094 .map(|session| (record.record_id.clone(), session))
7095 })
7096 .collect();
7097 for (record_id, session) in candidates {
7098 let shared = Arc::clone(self);
7099 tokio::spawn(async move {
7100 shared
7101 .reconcile_context_pack_export_for_record(&record_id, session)
7102 .await;
7103 });
7104 }
7105 }
7106
7107 async fn reconcile_context_pack_export_for_record(
7126 &self,
7127 record_id: &SessionRecordId,
7128 session: SessionAddress,
7129 ) {
7130 let Ok(record) = self.record(record_id) else {
7131 return;
7132 };
7133 if !matches!(
7134 record.state,
7135 ManagedSessionState::Live | ManagedSessionState::Dormant
7136 ) || record.exported_context.is_some()
7137 || record.provider_session.is_none()
7138 {
7139 return;
7140 }
7141 let Ok(receipt) = self
7148 .export_context_pack_for_session_record(&record.record_id, &session, true)
7149 .await
7150 else {
7151 return;
7152 };
7153 self.set_exported_context(&record.record_id, receipt);
7154 }
7155
7156 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7161 let transaction = self
7162 .state_transaction
7163 .lock()
7164 .unwrap_or_else(|poisoned| poisoned.into_inner());
7165 let (previous, updated) = {
7166 let mut records = self
7167 .session_records
7168 .lock()
7169 .unwrap_or_else(|poisoned| poisoned.into_inner());
7170 let Some(record) = records.records.get_mut(record_id) else {
7171 return;
7172 };
7173 if record.exported_context.is_some() {
7174 return;
7175 }
7176 let previous = record.clone();
7177 record.exported_context = Some(receipt);
7178 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7179 (previous, record.clone())
7180 };
7181 if self.persist_state_locked().is_err() {
7182 self.session_records
7183 .lock()
7184 .unwrap_or_else(|poisoned| poisoned.into_inner())
7185 .records
7186 .insert(record_id.clone(), previous);
7187 return;
7188 }
7189 drop(transaction);
7190 self.publish_record(updated);
7191 }
7192
7193 fn managed_record_export_target_for_instance(
7203 &self,
7204 instance_id: AgentInstanceId,
7205 generation: SessionGeneration,
7206 ) -> Option<(SessionRecordId, SessionAddress)> {
7207 let bindings = self
7208 .session_bindings
7209 .lock()
7210 .unwrap_or_else(|poisoned| poisoned.into_inner());
7211 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7212 let record_id = binding.record_id.clone()?;
7213 Some((
7214 record_id,
7215 SessionAddress {
7216 workspace_id: binding.workspace_id.clone(),
7217 session: SessionKey { instance_id, generation },
7218 },
7219 ))
7220 }
7221
7222 fn mark_managed_recovery_required(
7223 &self,
7224 lease_id: &ManagedWorktreeLeaseId,
7225 failure_kind: ManagedWorktreeCleanupFailure,
7226 ) {
7227 let snapshot = {
7228 let mut registry = self.managed_worktrees.lock()
7229 .unwrap_or_else(|poisoned| poisoned.into_inner());
7230 let Some(lease) = registry.get_mut(lease_id) else { return };
7231 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7232 lease.cleanup_failure = Some(failure_kind);
7233 lease.updated_at_unix_ms = unix_time_ms();
7234 lease.snapshot()
7235 };
7236 let _ = self.persist_state();
7237 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7238 }
7239
7240 fn set_persistence_error(&self, error: Option<String>) {
7241 *self
7242 .persistence_error
7243 .write()
7244 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7245 .map(|message| session_registry::sanitized_persistence_summary(&message));
7246 }
7247
7248 fn persist_state(&self) -> io::Result<()> {
7249 let _transaction = self
7250 .state_transaction
7251 .lock()
7252 .unwrap_or_else(|poisoned| poisoned.into_inner());
7253 self.persist_state_locked()
7254 }
7255
7256 fn persist_state_locked(&self) -> io::Result<()> {
7257 let workspaces = self
7258 .workspaces
7259 .read()
7260 .unwrap_or_else(|poisoned| poisoned.into_inner())
7261 .clone();
7262 let records = self
7263 .session_records
7264 .lock()
7265 .unwrap_or_else(|poisoned| poisoned.into_inner())
7266 .records
7267 .values()
7268 .cloned()
7269 .collect::<Vec<_>>();
7270 let mut managed = self.managed_worktrees
7271 .lock()
7272 .unwrap_or_else(|poisoned| poisoned.into_inner());
7273 managed.reattach_record_holders(
7274 &managed_worktree_record_holders(&records),
7275 unix_time_ms(),
7276 );
7277 let materializations = self
7278 .materializations
7279 .lock()
7280 .unwrap_or_else(|poisoned| poisoned.into_inner())
7281 .values()
7282 .cloned()
7283 .collect::<Vec<_>>();
7284 let managed_spawn_replays = self.managed_spawn_replays
7285 .lock()
7286 .unwrap_or_else(|poisoned| poisoned.into_inner())
7287 .values()
7288 .cloned()
7289 .collect::<Vec<_>>();
7290 let result = session_registry::save_v10(
7291 self.state_path.as_deref(),
7292 &self.node_id,
7293 &workspaces,
7294 &records,
7295 &managed.records(),
7296 &managed.tombstones(),
7297 &materializations,
7298 &managed_spawn_replays,
7299 );
7300 match &result {
7301 Ok(warning) => self.set_persistence_error(warning.clone()),
7302 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7303 }
7304 result.map(|_| ())
7305 }
7306
7307 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7308 let _mutation_guard = self.mutation_gate.lock().await;
7309 self.begin_shutdown_locked().await
7310 }
7311
7312 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7313 if self.shutdown.swap(true, Ordering::AcqRel) {
7314 return Ok(());
7315 }
7316 SessionLifecycleHook::on_shutdown(self);
7317 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7318 let mut first_error = None;
7319 for session in &self.handle.snapshot().sessions {
7320 let remaining = deadline.saturating_duration_since(Instant::now());
7321 if remaining.is_zero() {
7322 first_error.get_or_insert_with(|| {
7323 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7324 });
7325 break;
7326 }
7327 if let Err(error) = self
7328 .dispatch_bounded(
7329 ControlCommand::Stop {
7330 instance_id: session.instance_id,
7331 force: true,
7332 },
7333 remaining,
7334 )
7335 .await
7336 {
7337 first_error.get_or_insert(error);
7338 }
7339 }
7340 self.shutdown_notify.notify_waiters();
7341 match first_error {
7342 Some(error) => Err(error),
7343 None => Ok(()),
7344 }
7345 }
7346
7347 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7348 let bindings = self
7349 .session_bindings
7350 .lock()
7351 .unwrap_or_else(|poisoned| poisoned.into_inner());
7352 if bindings.len() >= CONTROL_SESSIONS_MAX {
7353 Err(failure(
7354 NodeFailureCode::BackendBusy,
7355 "node session-binding capacity is full; remove an existing session first",
7356 ))
7357 } else {
7358 Ok(())
7359 }
7360 }
7361
7362 fn ensure_workspace_environment_profile_compatible(
7369 &self,
7370 workspace_id: &WorkspaceId,
7371 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7372 ) -> Result<(), NodeFailure> {
7373 let bindings = self
7374 .session_bindings
7375 .lock()
7376 .unwrap_or_else(|poisoned| poisoned.into_inner());
7377 for binding in bindings.values() {
7378 if &binding.workspace_id != workspace_id {
7379 continue;
7380 }
7381 if binding.environment_profile.as_ref() != environment_profile {
7382 return Err(failure(
7383 NodeFailureCode::WorkspaceBusy,
7384 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7385 ));
7386 }
7387 }
7388 Ok(())
7389 }
7390
7391 #[cfg(test)]
7392 fn bind_session(
7393 &self,
7394 instance_id: AgentInstanceId,
7395 workspace_id: WorkspaceId,
7396 generation: SessionGeneration,
7397 ) {
7398 self.bind_session_with_policy(
7399 &SessionAddress {
7400 workspace_id,
7401 session: SessionKey { instance_id, generation },
7402 },
7403 ProviderRuntimePolicy::raw_pty(),
7404 None,
7405 );
7406 }
7407
7408 #[cfg(test)]
7409 fn bind_session_with_policy(
7410 &self,
7411 address: &SessionAddress,
7412 runtime_policy: ProviderRuntimePolicy,
7413 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7414 ) {
7415 self.bind_session_with_materialization(
7416 address,
7417 runtime_policy,
7418 environment_profile,
7419 None,
7420 None,
7421 None,
7422 );
7423 }
7424
7425 fn bind_session_with_materialization(
7426 &self,
7427 address: &SessionAddress,
7428 runtime_policy: ProviderRuntimePolicy,
7429 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7430 bundle: Option<ResolvedBundleReceipt>,
7431 context: Option<ResolvedContextPackReceipt>,
7432 materialization_id: Option<MaterializationId>,
7433 ) {
7434 let mut bindings = self
7435 .session_bindings
7436 .lock()
7437 .unwrap_or_else(|poisoned| poisoned.into_inner());
7438 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7439 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7440 bindings.insert(address.session.instance_id, SessionBinding {
7441 workspace_id: address.workspace_id.clone(),
7442 generation: address.session.generation,
7443 runtime_policy,
7444 pending_resume: None,
7445 record_id: None,
7446 managed_worktree_lease_id: None,
7447 environment_profile,
7448 bundle,
7449 context,
7450 materialization_id,
7451 });
7452 }
7453
7454 #[cfg(test)]
7455 fn bind_managed_session(
7456 &self,
7457 address: &SessionAddress,
7458 record_id: SessionRecordId,
7459 runtime_policy: ProviderRuntimePolicy,
7460 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7461 ) {
7462 self.bind_managed_session_with_materialization(
7463 address,
7464 record_id,
7465 runtime_policy,
7466 environment_profile,
7467 None,
7468 None,
7469 None,
7470 );
7471 }
7472
7473 fn bind_managed_session_with_materialization(
7474 &self,
7475 address: &SessionAddress,
7476 record_id: SessionRecordId,
7477 runtime_policy: ProviderRuntimePolicy,
7478 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7479 bundle: Option<ResolvedBundleReceipt>,
7480 context: Option<ResolvedContextPackReceipt>,
7481 materialization_id: Option<MaterializationId>,
7482 ) {
7483 let mut bindings = self
7484 .session_bindings
7485 .lock()
7486 .unwrap_or_else(|poisoned| poisoned.into_inner());
7487 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7488 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7489 bindings.insert(
7490 address.session.instance_id,
7491 SessionBinding {
7492 workspace_id: address.workspace_id.clone(),
7493 generation: address.session.generation,
7494 runtime_policy,
7495 pending_resume: None,
7496 record_id: Some(record_id),
7497 managed_worktree_lease_id: None,
7498 environment_profile,
7499 bundle,
7500 context,
7501 materialization_id,
7502 },
7503 );
7504 }
7505
7506 #[cfg(test)]
7507 fn bind_spawn_session(
7508 &self,
7509 address: &SessionAddress,
7510 provider: AgentId,
7511 mode: SessionMode,
7512 runtime_policy: ProviderRuntimePolicy,
7513 record_policy: SpawnRecordPolicy,
7514 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7515 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7516 self.bind_spawn_session_with_materialization(
7517 address,
7518 provider,
7519 mode,
7520 runtime_policy,
7521 record_policy,
7522 environment_profile,
7523 None,
7524 None,
7525 None,
7526 )
7527 }
7528
7529 fn bind_spawn_session_with_materialization(
7530 &self,
7531 address: &SessionAddress,
7532 provider: AgentId,
7533 mode: SessionMode,
7534 runtime_policy: ProviderRuntimePolicy,
7535 record_policy: SpawnRecordPolicy,
7536 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7537 bundle: Option<ResolvedBundleReceipt>,
7538 context: Option<ResolvedContextPackReceipt>,
7539 materialization_id: Option<MaterializationId>,
7540 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7541 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7542 && !runtime_policy.provider_session_identity
7543 {
7544 self.bind_session_with_materialization(
7545 address,
7546 runtime_policy,
7547 environment_profile,
7548 bundle,
7549 context,
7550 materialization_id,
7551 );
7552 return Ok(None);
7553 }
7554 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7555 let record = self.new_record(
7556 address,
7557 provider,
7558 mode,
7559 active_record_state(runtime_policy.provider_session_identity, false),
7560 None,
7561 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7562 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7563 record_owns_lifecycle.then(|| context.clone()).flatten(),
7564 )?;
7565 let record_id = record.record_id.clone();
7566 let transaction = self
7567 .state_transaction
7568 .lock()
7569 .unwrap_or_else(|poisoned| poisoned.into_inner());
7570 self.insert_record(record.clone())?;
7571 let previous_materialization = if record_owns_lifecycle {
7572 if let Some(materialization_id) = materialization_id.as_ref() {
7573 let mut materializations = self
7574 .materializations
7575 .lock()
7576 .unwrap_or_else(|poisoned| poisoned.into_inner());
7577 let Some(ownership) = materializations.get_mut(materialization_id) else {
7578 drop(materializations);
7579 self.remove_record_memory(&record_id);
7580 return Err(failure(
7581 NodeFailureCode::BackendOperationFailed,
7582 "session environment ownership disappeared before binding",
7583 ));
7584 };
7585 let previous = ownership.clone();
7586 if ownership
7587 .transfer_to_record(record_id.clone(), unix_time_ms())
7588 .is_err()
7589 {
7590 drop(materializations);
7591 self.remove_record_memory(&record_id);
7592 return Err(failure(
7593 NodeFailureCode::BackendOperationFailed,
7594 "session environment ownership transfer failed",
7595 ));
7596 }
7597 Some(previous)
7598 } else {
7599 None
7600 }
7601 } else {
7602 None
7603 };
7604 self.bind_managed_session_with_materialization(
7605 address,
7606 record_id.clone(),
7607 runtime_policy,
7608 environment_profile,
7609 bundle,
7610 context,
7611 materialization_id.clone(),
7612 );
7613 if let Err(error) = self.persist_state_locked() {
7614 self.remove_binding(address);
7615 self.remove_record_memory(&record_id);
7616 if let (Some(materialization_id), Some(previous)) =
7617 (materialization_id.as_ref(), previous_materialization)
7618 {
7619 self.materializations
7620 .lock()
7621 .unwrap_or_else(|poisoned| poisoned.into_inner())
7622 .insert(materialization_id.clone(), previous);
7623 }
7624 return Err(persistence_failure(error));
7625 }
7626 drop(transaction);
7627 self.publish_record(record);
7628 Ok(Some(record_id))
7629 }
7630
7631 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7632 for _ in 0..8 {
7633 let bytes = random_nonce().map_err(|error| {
7634 failure(
7635 NodeFailureCode::BackendOperationFailed,
7636 &format!("session record identity generation failed: {error}"),
7637 )
7638 })?;
7639 let mut value = String::with_capacity(27);
7640 value.push_str("sr-");
7641 for byte in &bytes[..12] {
7642 use std::fmt::Write as _;
7643 let _ = write!(&mut value, "{byte:02x}");
7644 }
7645 let record_id = SessionRecordId::new(value).map_err(|error| {
7646 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7647 })?;
7648 let records = self
7649 .session_records
7650 .lock()
7651 .unwrap_or_else(|poisoned| poisoned.into_inner());
7652 if !records.records.contains_key(&record_id) {
7653 return Ok(record_id);
7654 }
7655 }
7656 Err(failure(
7657 NodeFailureCode::BackendBusy,
7658 "could not allocate a unique session record ID",
7659 ))
7660 }
7661
7662 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7663 for _ in 0..8 {
7664 let bytes = random_nonce().map_err(|error| {
7665 failure(
7666 NodeFailureCode::BackendOperationFailed,
7667 &format!("task identity generation failed: {error}"),
7668 )
7669 })?;
7670 let mut nonce = [0_u8; 12];
7671 nonce.copy_from_slice(&bytes[..12]);
7672 let task_id = TaskId::from_nonce(nonce);
7673 let records = self
7674 .session_records
7675 .lock()
7676 .unwrap_or_else(|poisoned| poisoned.into_inner());
7677 if !records.records.values().any(|record| {
7678 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7679 == Some(&task_id)
7680 }) {
7681 return Ok(task_id);
7682 }
7683 }
7684 Err(failure(
7685 NodeFailureCode::BackendBusy,
7686 "could not allocate a unique task ID",
7687 ))
7688 }
7689
7690 fn default_record_name(&self, provider: &AgentId) -> String {
7691 let records = self
7692 .session_records
7693 .lock()
7694 .unwrap_or_else(|poisoned| poisoned.into_inner());
7695 let ordinal = records
7696 .records
7697 .values()
7698 .filter(|record| &record.provider == provider)
7699 .count()
7700 .saturating_add(1);
7701 format!("{} #{ordinal}", provider.as_str())
7702 }
7703
7704 fn new_record(
7705 &self,
7706 address: &SessionAddress,
7707 provider: AgentId,
7708 mode: SessionMode,
7709 state: ManagedSessionState,
7710 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7711 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7712 bundle: Option<ResolvedBundleReceipt>,
7713 context: Option<ResolvedContextPackReceipt>,
7714 ) -> Result<ManagedSessionRecord, NodeFailure> {
7715 let canonical_root = self.workspace_root(&address.workspace_id)?;
7716 let now = unix_time_ms();
7717 Ok(ManagedSessionRecord {
7718 record_id: self.allocate_record_id()?,
7719 display_name: self.default_record_name(&provider),
7720 provider,
7721 mode,
7722 state,
7723 workspace_id: address.workspace_id.clone(),
7724 canonical_root: opaque_windows_path(canonical_root),
7725 provider_session,
7726 active_session: Some(address.clone()),
7727 environment_profile,
7728 bundle,
7729 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7730 context,
7731 exported_context: None,
7732 task_binding: None,
7733 created_at_unix_ms: now,
7734 updated_at_unix_ms: now,
7735 last_error: None,
7736 })
7737 }
7738
7739 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7740 let mut records = self
7741 .session_records
7742 .lock()
7743 .unwrap_or_else(|poisoned| poisoned.into_inner());
7744 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7745 return Err(failure(
7746 NodeFailureCode::BackendBusy,
7747 "managed session record capacity is full; forget an inactive record first",
7748 ));
7749 }
7750 if let Some(identity) = record.provider_session.as_ref() {
7751 if records.records.values().any(|candidate| {
7752 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7753 session_registry::same_provider_session(
7754 &candidate.provider,
7755 candidate_identity,
7756 &record.provider,
7757 identity,
7758 )
7759 })
7760 }) {
7761 return Err(failure(
7762 NodeFailureCode::SessionRecordConflict,
7763 "provider session is already represented by another managed record",
7764 ));
7765 }
7766 }
7767 match records.records.entry(record.record_id.clone()) {
7768 std::collections::btree_map::Entry::Vacant(entry) => {
7769 entry.insert(record);
7770 }
7771 std::collections::btree_map::Entry::Occupied(_) => {
7772 return Err(failure(
7773 NodeFailureCode::SessionRecordConflict,
7774 "session record ID collision",
7775 ));
7776 }
7777 }
7778 Ok(())
7779 }
7780
7781 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7782 self.session_records
7783 .lock()
7784 .unwrap_or_else(|poisoned| poisoned.into_inner())
7785 .records
7786 .get(record_id)
7787 .cloned()
7788 .ok_or_else(|| {
7789 failure(
7790 NodeFailureCode::UnknownSessionRecord,
7791 "managed session record does not exist",
7792 )
7793 })
7794 }
7795
7796 fn revalidate_session_record_preview(
7797 &self,
7798 expected: &ManagedSessionRecord,
7799 identity: &ProviderSessionIdentity,
7800 ) -> Result<(), NodeFailure> {
7801 let current = self.record(&expected.record_id)?;
7802 let current_root = self.workspace_root(¤t.workspace_id)?;
7803 if current.provider != expected.provider
7804 || current.workspace_id != expected.workspace_id
7805 || current.canonical_root != expected.canonical_root
7806 || current.provider_session.as_ref() != Some(identity)
7807 || !platform::roots_equal(
7808 ¤t_root,
7809 windows_path_text(¤t.canonical_root),
7810 )
7811 {
7812 return Err(failure(
7813 NodeFailureCode::SessionRecordConflict,
7814 "managed session changed while its preview was loading",
7815 ));
7816 }
7817 Ok(())
7818 }
7819
7820 fn revalidate_session_record_context_export(
7821 &self,
7822 expected: &ManagedSessionRecord,
7823 identity: Option<&ProviderSessionIdentity>,
7824 session: &SessionAddress,
7825 allow_clean_detachment: bool,
7826 ) -> Result<(), NodeFailure> {
7827 let current = self.record(&expected.record_id)?;
7828 let current_root = self.workspace_root(¤t.workspace_id)?;
7829 let snapshot = self.internal_session_snapshot(session)?;
7830 let binding = self
7831 .session_bindings
7832 .lock()
7833 .unwrap_or_else(|poisoned| poisoned.into_inner())
7834 .get(&session.session.instance_id)
7835 .cloned()
7836 .ok_or_else(|| {
7837 failure(
7838 NodeFailureCode::SessionRecordConflict,
7839 "managed session runtime binding disappeared during context export",
7840 )
7841 })?;
7842 if !session_record_context_export_binding_is_exact(
7843 expected,
7844 ¤t,
7845 identity,
7846 session,
7847 &binding,
7848 &snapshot.agent_id,
7849 ¤t_root,
7850 allow_clean_detachment,
7851 ) || !session_record_context_export_source_is_usable(
7852 ¤t,
7853 &snapshot.status,
7854 allow_clean_detachment,
7855 ) {
7856 return Err(failure(
7857 NodeFailureCode::SessionRecordConflict,
7858 "managed session binding changed during context export",
7859 ));
7860 }
7861 Ok(())
7862 }
7863
7864 fn revalidate_loaded_history(
7865 &self,
7866 session: &SessionAddress,
7867 candidate_id: &str,
7868 loaded: &HistorySessionRecord,
7869 ) -> Result<(), NodeFailure> {
7870 let snapshot = self.internal_session_snapshot(session)?;
7871 if snapshot.history.pending.is_some()
7872 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7873 || snapshot.history.loaded.as_ref() != Some(loaded)
7874 {
7875 return Err(failure(
7876 NodeFailureCode::SessionRecordConflict,
7877 "managed session history changed during context export",
7878 ));
7879 }
7880 Ok(())
7881 }
7882
7883 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7884 self.session_records
7885 .lock()
7886 .unwrap_or_else(|poisoned| poisoned.into_inner())
7887 .records
7888 .remove(record_id)
7889 }
7890
7891 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7892 if self.destroy_record_materialization(record_id)? {
7893 self.publish(NodeEvent::SessionRecordRemoved {
7894 record_id: record_id.clone(),
7895 });
7896 return Ok(());
7897 }
7898 let transaction = self
7899 .state_transaction
7900 .lock()
7901 .unwrap_or_else(|poisoned| poisoned.into_inner());
7902 let Some(removed) = self.remove_record_memory(record_id) else {
7903 return Ok(());
7904 };
7905 if let Err(error) = self.persist_state_locked() {
7906 let _ = self.insert_record(removed);
7907 return Err(persistence_failure(error));
7908 }
7909 drop(transaction);
7910 self.publish(NodeEvent::SessionRecordRemoved {
7911 record_id: record_id.clone(),
7912 });
7913 Ok(())
7914 }
7915
7916 fn publish_record(&self, record: ManagedSessionRecord) {
7917 self.publish(NodeEvent::SessionRecordUpserted { record });
7918 }
7919
7920 fn mark_record_error(
7921 &self,
7922 record_id: &SessionRecordId,
7923 message: &str,
7924 ) -> Result<(), NodeFailure> {
7925 let transaction = self
7926 .state_transaction
7927 .lock()
7928 .unwrap_or_else(|poisoned| poisoned.into_inner());
7929 let previous = {
7930 let mut records = self
7931 .session_records
7932 .lock()
7933 .unwrap_or_else(|poisoned| poisoned.into_inner());
7934 let Some(record) = records.records.get_mut(record_id) else {
7935 return Ok(());
7936 };
7937 let previous = record.clone();
7938 record.state = ManagedSessionState::Unavailable;
7939 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
7940 record.updated_at_unix_ms = unix_time_ms();
7941 previous
7942 };
7943 if let Err(error) = self.persist_state_locked() {
7944 self.session_records
7945 .lock()
7946 .unwrap_or_else(|poisoned| poisoned.into_inner())
7947 .records
7948 .insert(record_id.clone(), previous);
7949 return Err(persistence_failure(error));
7950 }
7951 let record = self.record(record_id)?;
7952 drop(transaction);
7953 self.publish_record(record);
7954 Ok(())
7955 }
7956
7957 fn rename_session_record(
7958 &self,
7959 record_id: &SessionRecordId,
7960 display_name: String,
7961 ) -> Result<ManagedSessionRecord, NodeFailure> {
7962 validate_display_name(&display_name)
7963 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
7964 let _transaction = self
7965 .state_transaction
7966 .lock()
7967 .unwrap_or_else(|poisoned| poisoned.into_inner());
7968 let previous = {
7969 let mut records = self
7970 .session_records
7971 .lock()
7972 .unwrap_or_else(|poisoned| poisoned.into_inner());
7973 let record = records.records.get_mut(record_id).ok_or_else(|| {
7974 failure(
7975 NodeFailureCode::UnknownSessionRecord,
7976 "managed session record does not exist",
7977 )
7978 })?;
7979 let previous = record.clone();
7980 record.display_name = display_name;
7981 record.updated_at_unix_ms = unix_time_ms();
7982 previous
7983 };
7984 if let Err(error) = self.persist_state_locked() {
7985 self.session_records
7986 .lock()
7987 .unwrap_or_else(|poisoned| poisoned.into_inner())
7988 .records
7989 .insert(record_id.clone(), previous);
7990 return Err(persistence_failure(error));
7991 }
7992 let updated = self.record(record_id)?;
7993 self.publish_record(updated.clone());
7994 Ok(updated)
7995 }
7996
7997 fn set_session_task(
7998 &self,
7999 record_id: &SessionRecordId,
8000 expected_revision: u64,
8001 target: SessionTaskTargetV1,
8002 ) -> Result<ManagedSessionRecord, NodeFailure> {
8003 let transaction = self
8004 .state_transaction
8005 .lock()
8006 .unwrap_or_else(|poisoned| poisoned.into_inner());
8007 let current = self.record(record_id)?;
8008 let current_task_id = current
8009 .task_binding
8010 .as_ref()
8011 .and_then(|binding| binding.task_id.as_ref());
8012 let current_revision = current
8013 .task_binding
8014 .as_ref()
8015 .map_or(0, |binding| binding.revision);
8016 if expected_revision != current_revision {
8017 return Err(failure(
8018 NodeFailureCode::SessionRecordConflict,
8019 "managed session task binding revision changed",
8020 ));
8021 }
8022 let exact = match &target {
8023 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8024 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8025 SessionTaskTargetV1::New => false,
8026 };
8027 if exact {
8028 return Ok(current);
8029 }
8030 let revision = current_revision.checked_add(1).ok_or_else(|| {
8031 failure(
8032 NodeFailureCode::SessionRecordConflict,
8033 "managed session task binding revision is exhausted",
8034 )
8035 })?;
8036 let task_id = match target {
8037 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8038 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8039 SessionTaskTargetV1::Clear => None,
8040 };
8041 let changed_at_unix_ms = unix_time_ms()
8042 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8043 failure(
8044 NodeFailureCode::SessionRecordConflict,
8045 "managed session record timestamp is exhausted",
8046 )
8047 })?);
8048 let updated = {
8049 let mut records = self
8050 .session_records
8051 .lock()
8052 .unwrap_or_else(|poisoned| poisoned.into_inner());
8053 let record = records.records.get_mut(record_id).ok_or_else(|| {
8054 failure(
8055 NodeFailureCode::UnknownSessionRecord,
8056 "managed session record does not exist",
8057 )
8058 })?;
8059 record.task_binding = Some(SessionTaskBindingV1 {
8060 revision,
8061 task_id,
8062 changed_at_unix_ms,
8063 });
8064 record.updated_at_unix_ms = changed_at_unix_ms;
8065 record.clone()
8066 };
8067 if let Err(error) = self.persist_state_locked() {
8068 self.session_records
8069 .lock()
8070 .unwrap_or_else(|poisoned| poisoned.into_inner())
8071 .records
8072 .insert(record_id.clone(), current);
8073 return Err(persistence_failure(error));
8074 }
8075 drop(transaction);
8076 self.publish_record(updated.clone());
8077 Ok(updated)
8078 }
8079
8080 fn index_provider_session(
8081 &self,
8082 workspace_id: WorkspaceId,
8083 provider: AgentId,
8084 identity: ProviderSessionIdentity,
8085 display_name: String,
8086 ) -> Result<ManagedSessionRecord, NodeFailure> {
8087 self.index_provider_session_with_policy(
8088 workspace_id,
8089 provider,
8090 identity,
8091 display_name,
8092 false,
8093 )
8094 }
8095
8096 fn index_provider_session_with_policy(
8097 &self,
8098 workspace_id: WorkspaceId,
8099 provider: AgentId,
8100 identity: ProviderSessionIdentity,
8101 display_name: String,
8102 allow_validated_transcript_path: bool,
8103 ) -> Result<ManagedSessionRecord, NodeFailure> {
8104 validate_display_name(&display_name)
8105 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8106 identity
8107 .validate()
8108 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8109 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8110 return Err(failure(
8111 NodeFailureCode::InvalidRequest,
8112 "provider session reference index v1 does not accept transcript paths",
8113 ));
8114 }
8115 let canonical_root = self.workspace_root(&workspace_id)?;
8116 if !self.enabled_providers.contains(&provider) {
8117 return Err(failure(
8118 NodeFailureCode::InvalidRequest,
8119 "provider is not enabled on this node",
8120 ));
8121 }
8122
8123 let transaction = self
8124 .state_transaction
8125 .lock()
8126 .unwrap_or_else(|poisoned| poisoned.into_inner());
8127 {
8128 let records = self
8129 .session_records
8130 .lock()
8131 .unwrap_or_else(|poisoned| poisoned.into_inner());
8132 if let Some(existing) = records.records.values().find(|candidate| {
8133 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8134 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8135 })
8136 }) {
8137 if existing.provider == provider && existing.workspace_id == workspace_id {
8138 return Ok(existing.clone());
8139 }
8140 return Err(failure(
8141 NodeFailureCode::SessionRecordConflict,
8142 "provider session reference conflicts with another provider or workspace",
8143 ));
8144 }
8145 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8146 return Err(failure(
8147 NodeFailureCode::BackendBusy,
8148 "managed session record capacity is full; forget an inactive record first",
8149 ));
8150 }
8151 }
8152
8153 let now = unix_time_ms();
8154 let record = ManagedSessionRecord {
8155 record_id: self.allocate_record_id()?,
8156 display_name,
8157 provider,
8158 mode: SessionMode::Pty,
8159 state: ManagedSessionState::Dormant,
8160 workspace_id,
8161 canonical_root: opaque_windows_path(canonical_root),
8162 provider_session: Some(identity),
8163 active_session: None,
8164 environment_profile: None,
8165 bundle: None,
8166 context_id: None,
8167 context: None,
8168 exported_context: None,
8169 task_binding: None,
8170 created_at_unix_ms: now,
8171 updated_at_unix_ms: now,
8172 last_error: None,
8173 };
8174 self.insert_record(record.clone())?;
8175 if let Err(error) = self.persist_state_locked() {
8176 self.remove_record_memory(&record.record_id);
8177 return Err(persistence_failure(error));
8178 }
8179 drop(transaction);
8180 self.publish_record(record.clone());
8181 Ok(record)
8182 }
8183
8184 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8185 self.session_bindings
8186 .lock()
8187 .unwrap_or_else(|poisoned| poisoned.into_inner())
8188 .iter()
8189 .find_map(|(instance_id, binding)| {
8190 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8191 workspace_id: binding.workspace_id.clone(),
8192 session: SessionKey {
8193 instance_id: *instance_id,
8194 generation: binding.generation,
8195 },
8196 })
8197 })
8198 }
8199
8200 async fn forget_session_record(
8201 &self,
8202 record_id: &SessionRecordId,
8203 ) -> Result<(), NodeFailure> {
8204 let record = self.record(record_id)?;
8205 if record.active_session.is_some()
8206 || matches!(
8207 record.state,
8208 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8209 )
8210 {
8211 return Err(failure(
8212 NodeFailureCode::SessionRecordBusy,
8213 "stop the managed session before forgetting it",
8214 ));
8215 }
8216 if let Some(address) = self.bound_address_for_record(record_id) {
8217 self.remove_session(&address).await?;
8218 }
8219 self.remove_managed_session_record(record_id, &record.workspace_id).await
8220 }
8221
8222 async fn remove_managed_session_record(
8237 &self,
8238 record_id: &SessionRecordId,
8239 workspace_id: &WorkspaceId,
8240 ) -> Result<(), NodeFailure> {
8241 let managed_lease_id = self.managed_worktrees.lock()
8242 .unwrap_or_else(|poisoned| poisoned.into_inner())
8243 .lease_for_workspace(workspace_id);
8244 if !self.destroy_record_materialization(record_id)? {
8245 let transaction = self
8246 .state_transaction
8247 .lock()
8248 .unwrap_or_else(|poisoned| poisoned.into_inner());
8249 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8250 failure(
8251 NodeFailureCode::UnknownSessionRecord,
8252 "managed session record does not exist",
8253 )
8254 })?;
8255 if let Err(error) = self.persist_state_locked() {
8256 let _ = self.insert_record(removed);
8257 return Err(persistence_failure(error));
8258 }
8259 drop(transaction);
8260 }
8261 self.publish(NodeEvent::SessionRecordRemoved {
8262 record_id: record_id.clone(),
8263 });
8264 if let Some(lease_id) = managed_lease_id {
8265 if let Some(snapshot) = self.managed_worktrees.lock()
8266 .unwrap_or_else(|poisoned| poisoned.into_inner())
8267 .get(&lease_id)
8268 .map(ManagedWorktreeLeaseRecord::snapshot)
8269 {
8270 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8271 }
8272 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8273 }
8274 Ok(())
8275 }
8276
8277 async fn retire_unavailable_session_records(&self) {
8290 let policy = self.session_record_retention;
8291 let age_enabled = policy.age_ms > 0;
8292 let keep_enabled = policy.keep_per_workspace > 0;
8293 if !age_enabled && !keep_enabled {
8294 return;
8295 }
8296 let now = unix_time_ms();
8297 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8298 self.session_records
8299 .lock()
8300 .unwrap_or_else(|poisoned| poisoned.into_inner())
8301 .records
8302 .values()
8303 .filter(|record| record.state == ManagedSessionState::Unavailable)
8304 .map(|record| {
8305 (
8306 record.record_id.clone(),
8307 record.workspace_id.clone(),
8308 record.provider.clone(),
8309 record.updated_at_unix_ms,
8310 )
8311 })
8312 .collect()
8313 };
8314 if candidates.is_empty() {
8315 return;
8316 }
8317 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8318 BTreeMap::new();
8319 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8320 by_workspace
8321 .entry(workspace_id)
8322 .or_default()
8323 .push((record_id, provider, updated_at_unix_ms));
8324 }
8325 let reason = if age_enabled && keep_enabled {
8326 "age+keep-n"
8327 } else if age_enabled {
8328 "age"
8329 } else {
8330 "keep-n"
8331 };
8332 let mut retired: u64 = 0;
8333 for (workspace_id, mut records) in by_workspace {
8334 records.sort_by(|left, right| right.2.cmp(&left.2));
8337 for (index, (record_id, provider, updated_at_unix_ms)) in
8338 records.into_iter().enumerate()
8339 {
8340 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8341 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8342 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8343 let should_retire = if age_enabled && keep_enabled {
8344 age_hit && keep_hit
8345 } else {
8346 age_hit || keep_hit
8347 };
8348 if !should_retire {
8349 continue;
8350 }
8351 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8352 Ok(()) => {
8353 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8354 retired += 1;
8355 tracing::info!(
8356 node_id = %self.node_id,
8357 record_id = %record_id,
8358 workspace_id = %workspace_id,
8359 provider = %provider,
8360 age_ms = record_age_ms,
8361 reason,
8362 "managed session record retired"
8363 );
8364 }
8365 Err(error) => tracing::warn!(
8366 node_id = %self.node_id,
8367 record_id = %record_id,
8368 workspace_id = %workspace_id,
8369 code = ?error.code,
8370 cause = %error.message,
8371 "session record retention sweep failed to retire a record"
8372 ),
8373 }
8374 }
8375 }
8376 if retired > 0 {
8377 let remaining_unavailable = self
8378 .session_records
8379 .lock()
8380 .unwrap_or_else(|poisoned| poisoned.into_inner())
8381 .records
8382 .values()
8383 .filter(|record| record.state == ManagedSessionState::Unavailable)
8384 .count();
8385 tracing::info!(
8386 node_id = %self.node_id,
8387 retired,
8388 remaining_unavailable,
8389 "session record retention sweep completed"
8390 );
8391 }
8392 }
8393
8394 async fn resume_session_record(
8395 &self,
8396 record_id: &SessionRecordId,
8397 terminal_size: gate4agent_types::TerminalSize,
8398 initial_prompt: Option<String>,
8399 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8400 let record = self.record(record_id)?;
8401 if record.active_session.is_some()
8402 || matches!(
8403 record.state,
8404 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8405 )
8406 {
8407 return Err(failure(
8408 NodeFailureCode::SessionRecordBusy,
8409 "managed session already has a live runtime binding",
8410 ));
8411 }
8412 let identity = record.provider_session.clone().ok_or_else(|| {
8413 failure(
8414 NodeFailureCode::SessionRecordNotResumable,
8415 "managed session has no verified provider session identity",
8416 )
8417 })?;
8418 let working_directory = self.workspace_root(&record.workspace_id)?;
8419 if !platform::roots_equal(
8420 &working_directory,
8421 windows_path_text(&record.canonical_root),
8422 ) {
8423 return Err(failure(
8424 NodeFailureCode::SessionRecordConflict,
8425 "managed session workspace root changed; refusing to resume in another directory",
8426 ));
8427 }
8428 {
8429 let records = self
8430 .session_records
8431 .lock()
8432 .unwrap_or_else(|poisoned| poisoned.into_inner());
8433 if records.records.values().any(|candidate| {
8434 candidate.record_id != *record_id
8435 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8436 session_registry::same_provider_session(
8437 &candidate.provider,
8438 candidate_identity,
8439 &record.provider,
8440 &identity,
8441 )
8442 })
8443 && (candidate.active_session.is_some()
8444 || matches!(
8445 candidate.state,
8446 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8447 ))
8448 }) {
8449 return Err(failure(
8450 NodeFailureCode::SessionRecordConflict,
8451 "provider session is already active through another managed record",
8452 ));
8453 }
8454 }
8455 let runtime_requirement = if initial_prompt.is_some() {
8456 ProviderRuntimeRequirement::ResumeWithPrompt
8457 } else {
8458 ProviderRuntimeRequirement::Resume
8459 };
8460 let runtime_policy = self.admit_provider_runtime(
8461 &record.provider,
8462 runtime_requirement,
8463 )
8464 .await?;
8465 let request = ResumeLaunchRequest {
8466 working_directory,
8467 terminal_size,
8468 initial_prompt,
8469 };
8470 request
8471 .validate()
8472 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8473 self.ensure_binding_capacity()?;
8474 let resolved_materialization = match self.resolve_record_materialization(
8475 record_id,
8476 &record.provider,
8477 record.mode,
8478 record.environment_profile.as_ref(),
8479 record.bundle.as_ref(),
8480 record.context.as_ref(),
8481 ) {
8482 Ok(materialization) => materialization,
8483 Err(error) => {
8484 let label = match error.code {
8485 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8486 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8487 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8488 _ => "environment-profile-unavailable",
8489 };
8490 self.mark_record_error(record_id, label)?;
8491 return Err(error);
8492 }
8493 };
8494 let (materialization_id, environment_overlay) = match resolved_materialization {
8495 Some((id, overlay)) => (Some(id), overlay),
8496 None => (None, None),
8497 };
8498 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8499 let mut environment_selection = match SpawnPreparer::select_environment(self,
8500 instance_id,
8501 &record.provider,
8502 record.mode,
8503 record.environment_profile.as_ref(),
8504 ) {
8505 Ok(selection) => selection,
8506 Err(error) => {
8507 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8508 return Err(error);
8509 }
8510 };
8511 let mut instance_overlay = environment_overlay
8512 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8513 .transpose()?
8514 .flatten();
8515 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8516 self.remove_session(&stale_address).await?;
8517 }
8518 let address = SessionAddress {
8519 workspace_id: record.workspace_id.clone(),
8520 session: SessionKey {
8521 instance_id,
8522 generation: SessionGeneration(1),
8523 },
8524 };
8525 let transaction = self
8526 .state_transaction
8527 .lock()
8528 .unwrap_or_else(|poisoned| poisoned.into_inner());
8529 let current = self.record(record_id)?;
8530 if current.active_session.is_some()
8531 || matches!(
8532 current.state,
8533 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8534 )
8535 {
8536 return Err(failure(
8537 NodeFailureCode::SessionRecordBusy,
8538 "managed session became active while resume was being prepared",
8539 ));
8540 }
8541 self.bind_managed_session_with_materialization(
8542 &address,
8543 record_id.clone(),
8544 runtime_policy,
8545 record.environment_profile.clone(),
8546 record.bundle.clone(),
8547 record.context.clone(),
8548 materialization_id,
8549 );
8550 if let Some(selection) = environment_selection.take() {
8551 selection.retain();
8552 }
8553 if let Some(overlay) = instance_overlay.take() {
8554 overlay.retain();
8555 }
8556 drop(transaction);
8557
8558 let transport = match record.mode {
8559 SessionMode::Pty => TransportKind::Pty,
8560 SessionMode::Inline => TransportKind::Pipe,
8561 SessionMode::Acp => TransportKind::Acp,
8562 };
8563 let agent_id = record.provider.clone();
8564 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8565 if let Err(error) = self
8566 .dispatch_bounded(
8567 ControlCommand::Register {
8568 instance_id,
8569 agent_id,
8570 transport,
8571 },
8572 dispatch_timeout,
8573 )
8574 .await
8575 {
8576 if let Some(binding) = self.remove_binding(&address) {
8577 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8578 }
8579 return Err(error);
8580 }
8581 let command = self.prepare_command(ControlCommand::Resume {
8582 instance_id,
8583 target: ResumeTarget::ProviderSession { identity },
8584 runtime_policy,
8585 request,
8586 });
8587 let started_after = self.current_sequence();
8588 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8589 self.rollback_spawn(&address, dispatch_timeout).await?;
8590 return Err(error);
8591 }
8592 if let Err(error) = self.dispatch_envelope(command) {
8593 self.clear_armed_resume(&address);
8594 self.rollback_spawn(&address, dispatch_timeout).await?;
8595 return Err(error);
8596 }
8597 match self
8598 .wait_for_record_resume(&address, started_after)
8599 .await
8600 {
8601 Ok(settled_address) => {
8602 let managed_lease_id = {
8603 self.managed_worktrees.lock()
8604 .unwrap_or_else(|poisoned| poisoned.into_inner())
8605 .lease_for_workspace(&record.workspace_id)
8606 };
8607 if let Some(lease_id) = managed_lease_id {
8608 {
8609 let mut bindings = self.session_bindings.lock()
8610 .unwrap_or_else(|poisoned| poisoned.into_inner());
8611 let binding = bindings.get_mut(&settled_address.session.instance_id)
8612 .ok_or_else(|| failure(
8613 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8614 "resumed managed session lost its exact binding",
8615 ))?;
8616 if binding.generation != settled_address.session.generation {
8617 return Err(failure(
8618 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8619 "resumed managed session generation diverged",
8620 ));
8621 }
8622 binding.managed_worktree_lease_id = Some(lease_id.clone());
8623 }
8624 let snapshot = self.managed_worktrees.lock()
8625 .unwrap_or_else(|poisoned| poisoned.into_inner())
8626 .bind_session(
8627 &lease_id,
8628 ManagedWorktreeSessionHolder {
8629 incarnation_id: self.incarnation_id,
8630 instance_id: settled_address.session.instance_id,
8631 generation: settled_address.session.generation,
8632 },
8633 Some(record_id.clone()),
8634 unix_time_ms(),
8635 ).map_err(|message| failure(
8636 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8637 &message,
8638 ))?;
8639 if let Err(error) = self.persist_state() {
8640 let _ = self.remove_session(&settled_address).await;
8641 return Err(persistence_failure(error));
8642 }
8643 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8644 }
8645 Ok((self.record(record_id)?, settled_address))
8646 }
8647 Err(error) => {
8648 if matches!(
8649 error.code,
8650 NodeFailureCode::SessionRecordNotResumable
8651 | NodeFailureCode::BackendOperationFailed
8652 ) {
8653 if let Some(bound) = self.bound_address_for_record(record_id) {
8654 let _ = self.remove_session(&bound).await;
8655 }
8656 }
8657 Err(error)
8658 }
8659 }
8660 }
8661
8662 async fn wait_for_record_resume(
8663 &self,
8664 initial_address: &SessionAddress,
8665 after_sequence: u64,
8666 ) -> Result<SessionAddress, NodeFailure> {
8667 let deadline = Instant::now()
8668 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8669 let mut scan_after = after_sequence;
8670 loop {
8671 let events = self
8672 .history
8673 .lock()
8674 .unwrap_or_else(|poisoned| poisoned.into_inner())
8675 .events
8676 .iter()
8677 .filter(|event| event.sequence > scan_after)
8678 .cloned()
8679 .collect::<Vec<_>>();
8680 for envelope in events {
8681 scan_after = scan_after.max(envelope.sequence);
8682 let NodeEvent::Control { address, event } = envelope.event else {
8683 continue;
8684 };
8685 if address.session.instance_id != initial_address.session.instance_id {
8686 continue;
8687 }
8688 match event.event {
8689 ControlEventKind::Resumed { .. } => return Ok(address),
8690 ControlEventKind::ResumeDenied { reason } => {
8691 return Err(failure(
8692 NodeFailureCode::SessionRecordNotResumable,
8693 &reason,
8694 ));
8695 }
8696 ControlEventKind::ResumeFailed { message }
8697 | ControlEventKind::CommandRejected { message } => {
8698 return Err(failure(
8699 NodeFailureCode::BackendOperationFailed,
8700 &message,
8701 ));
8702 }
8703 _ => {}
8704 }
8705 }
8706 if Instant::now() >= deadline {
8707 return Err(failure(
8708 NodeFailureCode::BackendBusy,
8709 "managed resume did not settle before the bounded deadline",
8710 ));
8711 }
8712 sleep(Duration::from_millis(5)).await;
8713 }
8714 }
8715
8716 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8717 self.workspaces
8718 .read()
8719 .unwrap_or_else(|poisoned| poisoned.into_inner())
8720 .get(workspace_id)
8721 .cloned()
8722 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8723 }
8724
8725 fn workspace_roots(&self) -> Vec<PathBuf> {
8726 self.workspaces
8727 .read()
8728 .unwrap_or_else(|poisoned| poisoned.into_inner())
8729 .values()
8730 .map(PathBuf::from)
8731 .collect()
8732 }
8733
8734 async fn inspect_workspace(
8735 &self,
8736 workspace_id: WorkspaceId,
8737 ) -> Result<WorkspaceInspection, NodeFailure> {
8738 let _permit = self
8739 .inspection_slots
8740 .clone()
8741 .try_acquire_owned()
8742 .map_err(|_| {
8743 tracing::warn!(
8744 workspace_id = %workspace_id,
8745 "workspace inspection rejected: inspection capacity is busy",
8746 );
8747 failure(
8748 NodeFailureCode::BackendBusy,
8749 "workspace inspection capacity is busy",
8750 )
8751 })?;
8752 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8753 tracing::warn!(
8754 workspace_id = %workspace_id,
8755 code = ?error.code,
8756 "workspace inspection rejected: workspace is not registered",
8757 );
8758 error
8759 })?;
8760 let tree_root = canonical_root.clone();
8761 let started_at = Instant::now();
8762 let time_budget_ms = workspace_inspection_time_budget_ms();
8763 let entry_cap = workspace_inspection_entry_cap();
8764 let deadline = started_at + Duration::from_millis(time_budget_ms);
8765 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8766 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8767 })
8768 .await
8769 .map_err(|error| {
8770 tracing::warn!(
8771 workspace_id = %workspace_id,
8772 error = %error,
8773 "workspace inspection rejected: walk task failed",
8774 );
8775 failure(
8776 NodeFailureCode::BackendOperationFailed,
8777 &format!("workspace inspection task failed: {error}"),
8778 )
8779 })?;
8780 let (mut git, git_time_budget_exceeded) =
8781 inspect_git_workspace(&canonical_root, deadline).await;
8782 if !git.worktrees.is_empty() {
8783 let registered = self
8784 .workspaces
8785 .read()
8786 .unwrap_or_else(|poisoned| poisoned.into_inner())
8787 .iter()
8788 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8789 .collect::<Vec<_>>();
8790 for worktree in &mut git.worktrees {
8791 worktree.workspace_id = registered
8792 .iter()
8793 .find(|(_, root)| {
8794 worktree_paths_equal(root, windows_path_text(&worktree.path))
8795 })
8796 .map(|(workspace_id, _)| workspace_id.clone());
8797 }
8798 }
8799 if git.is_repository {
8800 git.managed_worktree = self
8801 .managed_worktrees
8802 .lock()
8803 .unwrap_or_else(|poisoned| poisoned.into_inner())
8804 .git_scope(&workspace_id, git.branch.as_deref());
8805 }
8806 let truncation = workspace_inspection_truncation(
8807 &walk_budget,
8808 git_time_budget_exceeded,
8809 started_at.elapsed(),
8810 );
8811 if let Some(truncation) = &truncation {
8812 tracing::info!(
8813 workspace_id = %workspace_id,
8814 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8815 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8816 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8817 entries_visited = truncation.entries_visited,
8818 elapsed_ms = truncation.elapsed_ms,
8819 "workspace inspection truncated by its inner budget",
8820 );
8821 }
8822 Ok(WorkspaceInspection {
8823 workspace_id,
8824 entries,
8825 tree_truncated,
8826 git,
8827 truncation,
8828 })
8829 }
8830
8831 async fn browse_host_directories(
8832 &self,
8833 directory: Option<OpaqueHostPath>,
8834 after: Option<OpaqueHostPath>,
8835 ) -> Result<HostDirectoryListing, NodeFailure> {
8836 let permit = self
8837 .inspection_slots
8838 .clone()
8839 .try_acquire_owned()
8840 .map_err(|_| {
8841 failure(
8842 NodeFailureCode::BackendBusy,
8843 "host directory browse capacity is busy",
8844 )
8845 })?;
8846 let task = tokio::task::spawn_blocking(move || {
8847 let _permit = permit;
8848 browse_host_directories(directory, after)
8849 });
8850 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8851 .await
8852 .map_err(|_| {
8853 failure(
8854 NodeFailureCode::HostDirectoryReadTimedOut,
8855 "host directory browse exceeded its bounded deadline",
8856 )
8857 })?
8858 .map_err(|_| {
8859 failure(
8860 NodeFailureCode::HostDirectoryReadFailed,
8861 "host directory browse task failed",
8862 )
8863 })?
8864 .map_err(host_directory_failure)
8865 }
8866
8867 async fn read_workspace_file(
8868 &self,
8869 workspace_id: WorkspaceId,
8870 path: RepositoryPath,
8871 ) -> Result<WorkspaceFileRead, NodeFailure> {
8872 let canonical_root = self.workspace_root(&workspace_id)?;
8873 let permit = self
8874 .inspection_slots
8875 .clone()
8876 .try_acquire_owned()
8877 .map_err(|_| {
8878 failure(
8879 NodeFailureCode::BackendBusy,
8880 "workspace file read capacity is busy",
8881 )
8882 })?;
8883 let path_to_read = path.clone();
8884 let task = tokio::task::spawn_blocking(move || {
8885 let _permit = permit;
8886 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8887 });
8888 let content = timeout(
8889 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8890 task,
8891 )
8892 .await
8893 .map_err(|_| {
8894 failure(
8895 NodeFailureCode::RepositoryFileReadTimedOut,
8896 "repository file read exceeded its bounded deadline",
8897 )
8898 })?
8899 .map_err(|_| {
8900 failure(
8901 NodeFailureCode::RepositoryFileReadFailed,
8902 "repository file read task failed",
8903 )
8904 })?
8905 .map_err(workspace_file_failure)?;
8906 let content = match content {
8907 WorkspaceFileBytes::Utf8(text) => {
8908 let revision = Some(workspace_file_revision(text.as_bytes()));
8909 return Ok(WorkspaceFileRead {
8910 workspace_id,
8911 path,
8912 content: WorkspaceFileContent::Utf8 {
8913 byte_len: u32::try_from(text.len())
8914 .expect("bounded workspace text length must fit u32"),
8915 text,
8916 },
8917 revision,
8918 });
8919 }
8920 WorkspaceFileBytes::NonUtf8 { byte_length } => {
8921 WorkspaceFileContent::NonUtf8 {
8922 byte_len: u32::try_from(byte_length)
8923 .expect("bounded workspace file length must fit u32"),
8924 }
8925 }
8926 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
8927 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
8928 },
8929 };
8930 Ok(WorkspaceFileRead {
8931 workspace_id,
8932 path,
8933 content,
8934 revision: None,
8935 })
8936 }
8937
8938 async fn write_workspace_file(
8939 &self,
8940 workspace_id: WorkspaceId,
8941 path: RepositoryPath,
8942 expected_revision: WorkspaceFileRevision,
8943 text: String,
8944 ) -> Result<WorkspaceFileRead, NodeFailure> {
8945 let canonical_root = self.workspace_root(&workspace_id)?;
8946 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8947 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
8948 })?;
8949 let path_to_write = path.clone();
8950 let expected = expected_revision.as_str().to_owned();
8951 let text_to_write = text.clone();
8952 let task = tokio::task::spawn_blocking(move || {
8953 let _permit = permit;
8954 write_workspace_file_to_disk(
8955 Path::new(&canonical_root),
8956 &path_to_write,
8957 &expected,
8958 &text_to_write,
8959 )
8960 });
8961 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
8962 .await
8963 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
8964 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
8965 .map_err(workspace_file_write_failure)?;
8966 Ok(WorkspaceFileRead {
8967 workspace_id,
8968 path,
8969 content: WorkspaceFileContent::Utf8 {
8970 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
8971 text,
8972 },
8973 revision: Some(WorkspaceFileRevision::new(revision)
8974 .expect("workspace writer returns a valid SHA-256 revision")),
8975 })
8976 }
8977
8978 async fn create_workspace_file(
8979 &self,
8980 workspace_id: WorkspaceId,
8981 path: RepositoryPath,
8982 ) -> Result<WorkspaceFileRead, NodeFailure> {
8983 let canonical_root = self.workspace_root(&workspace_id)?;
8984 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
8985 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
8986 })?;
8987 let path_to_create = path.clone();
8988 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
8989 let task_commit_state = Arc::clone(&commit_state);
8990 let task = tokio::task::spawn_blocking(move || {
8991 let _permit = permit;
8992 create_workspace_file_on_disk(
8993 Path::new(&canonical_root),
8994 &path_to_create,
8995 &task_commit_state,
8996 )
8997 });
8998 let revision = settle_workspace_entry_create(
8999 task,
9000 commit_state,
9001 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9002 "repository file create exceeded its bounded deadline",
9003 "repository file create task failed",
9004 )
9005 .await?;
9006 Ok(WorkspaceFileRead {
9007 workspace_id,
9008 path,
9009 content: WorkspaceFileContent::Utf8 {
9010 text: String::new(),
9011 byte_len: 0,
9012 },
9013 revision: Some(WorkspaceFileRevision::new(revision)
9014 .expect("workspace creator returns a valid SHA-256 revision")),
9015 })
9016 }
9017
9018 async fn create_workspace_directory(
9019 &self,
9020 workspace_id: WorkspaceId,
9021 path: RepositoryPath,
9022 ) -> Result<WorkspaceEntry, NodeFailure> {
9023 let canonical_root = self.workspace_root(&workspace_id)?;
9024 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9025 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9026 })?;
9027 let path_to_create = path.clone();
9028 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9029 let task_commit_state = Arc::clone(&commit_state);
9030 let task = tokio::task::spawn_blocking(move || {
9031 let _permit = permit;
9032 create_workspace_directory_on_disk(
9033 Path::new(&canonical_root),
9034 &path_to_create,
9035 &task_commit_state,
9036 )
9037 });
9038 settle_workspace_entry_create(
9039 task,
9040 commit_state,
9041 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9042 "repository directory create exceeded its bounded deadline",
9043 "repository directory create task failed",
9044 )
9045 .await?;
9046 Ok(WorkspaceEntry {
9047 relative_path: path,
9048 kind: WorkspaceEntryKind::Directory,
9049 })
9050 }
9051
9052 async fn read_git_history(
9053 &self,
9054 workspace_id: WorkspaceId,
9055 path: Option<RepositoryPath>,
9056 before: Option<GitObjectId>,
9057 limit: u16,
9058 ) -> Result<GitHistoryPage, NodeFailure> {
9059 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9060 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9061 })?;
9062 let root = self.workspace_root(&workspace_id)?;
9063 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9064 }
9065
9066 async fn read_git_diff(
9067 &self,
9068 workspace_id: WorkspaceId,
9069 request: GitDiffRequest,
9070 ) -> Result<GitDiff, NodeFailure> {
9071 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9072 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9073 })?;
9074 let root = self.workspace_root(&workspace_id)?;
9075 read_git_diff_bounded(&root, request).await
9076 }
9077
9078 fn native_session_route_root(
9079 &self,
9080 route: &NativeSessionCatalogRoute,
9081 ) -> Result<Option<String>, NodeFailure> {
9082 route
9083 .validate()
9084 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9085 match route.scope {
9086 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9087 .workspace_id
9088 .as_ref()
9089 .map(|workspace_id| self.workspace_root(workspace_id))
9090 .transpose(),
9091 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9092 }
9093 }
9094
9095 fn project_native_session_entries(
9096 &self,
9097 route: &NativeSessionCatalogRoute,
9098 entries: Vec<ScopedNativeSessionCatalogEntry>,
9099 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9100 let current_root = route
9101 .workspace_id
9102 .as_ref()
9103 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9104 let records = self
9105 .session_records
9106 .lock()
9107 .unwrap_or_else(|poisoned| poisoned.into_inner());
9108 let mut seen_records = std::collections::HashSet::new();
9109 entries
9110 .into_iter()
9111 .map(|entry| {
9112 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9113 let matches = records
9114 .records
9115 .values()
9116 .filter(|record| {
9117 record.provider == route.provider
9118 && record.workspace_id == *workspace_id
9119 && current_root.as_deref().is_some_and(|root| {
9120 platform::roots_equal(
9121 root,
9122 windows_path_text(&record.canonical_root),
9123 )
9124 })
9125 && record.provider_session.as_ref().is_some_and(|identity| {
9126 session_registry::same_provider_session(
9127 &record.provider,
9128 identity,
9129 &route.provider,
9130 &entry.provider_session,
9131 )
9132 })
9133 })
9134 .map(|record| record.record_id.clone())
9135 .collect::<Vec<_>>();
9136 (matches.len() == 1
9137 && seen_records.insert(matches[0].clone()))
9138 .then(|| matches[0].clone())
9139 });
9140 let metadata = entry.metadata;
9141 crate::protocol::NativeSessionCatalogEntry {
9142 selection_id: metadata.selection_id,
9143 title: metadata.title,
9144 modified_at_unix_ms: metadata.modified_at_unix_ms,
9145 model: metadata.model,
9146 message_count: metadata.message_count,
9147 completed_turn_count: metadata.completed_turn_count,
9148 external_group: entry.external_group,
9149 record_id,
9150 }
9151 })
9152 .collect()
9153 }
9154
9155 async fn catalog_native_sessions(
9156 &self,
9157 route: NativeSessionCatalogRoute,
9158 limit: u16,
9159 ) -> Result<(
9160 Vec<crate::protocol::NativeSessionCatalogEntry>,
9161 crate::protocol::NativeSessionCatalogSummary,
9162 ), NodeFailure> {
9163 let canonical_root = self.native_session_route_root(&route)?;
9164 let registered_workspace_roots = self.workspace_roots();
9165 let provider = route.provider.clone();
9166 let scope = route.scope;
9167 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9168 failure(
9169 NodeFailureCode::InvalidRequest,
9170 "native session catalog is not configured",
9171 )
9172 })?;
9173 let _permit = Arc::clone(&self.inspection_slots)
9174 .try_acquire_owned()
9175 .map_err(|_| {
9176 failure(
9177 NodeFailureCode::BackendBusy,
9178 "native session catalog capacity is busy",
9179 )
9180 })?;
9181 let page = run_native_session_catalog_operation(
9182 catalog,
9183 "native session catalog",
9184 move |catalog| {
9185 catalog.catalog_initial_for_scope(
9186 &provider,
9187 scope,
9188 canonical_root.as_deref().map(Path::new),
9189 ®istered_workspace_roots,
9190 limit,
9191 )
9192 },
9193 )
9194 .await?;
9195 let summary = crate::protocol::NativeSessionCatalogSummary {
9196 catalog_revision: page.revision,
9197 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9198 recent_total_count: page.recent_total_count,
9199 older_total_count: page.older_total_count,
9200 recent_next_after_selection_id: page.next_after_selection_id,
9201 recent_has_more: page.remaining_count > 0,
9202 };
9203 Ok((self.project_native_session_entries(&route, page.entries), summary))
9204 }
9205
9206 async fn page_native_sessions(
9207 &self,
9208 route: NativeSessionCatalogRoute,
9209 window: crate::protocol::NativeSessionCatalogWindow,
9210 catalog_revision: u64,
9211 recent_cutoff_unix_ms: u64,
9212 after_selection_id: Option<String>,
9213 limit: u16,
9214 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9215 let canonical_root = self.native_session_route_root(&route)?;
9216 let registered_workspace_roots = self.workspace_roots();
9217 let provider = route.provider.clone();
9218 let scope = route.scope;
9219 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9220 failure(
9221 NodeFailureCode::InvalidRequest,
9222 "native session catalog is not configured",
9223 )
9224 })?;
9225 let _permit = Arc::clone(&self.inspection_slots)
9226 .try_acquire_owned()
9227 .map_err(|_| {
9228 failure(
9229 NodeFailureCode::BackendBusy,
9230 "native session catalog capacity is busy",
9231 )
9232 })?;
9233 let page = run_native_session_catalog_operation(
9234 catalog,
9235 "native session catalog page",
9236 move |catalog| {
9237 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9238 &provider,
9239 scope,
9240 canonical_root.as_deref().map(Path::new),
9241 ®istered_workspace_roots,
9242 window,
9243 catalog_revision,
9244 recent_cutoff_unix_ms,
9245 after_selection_id.as_deref(),
9246 limit,
9247 ))
9248 },
9249 )
9250 .await?;
9251 let page = page.map_err(|error| match error {
9252 NativeSessionCatalogError::StaleCatalog => failure(
9253 NodeFailureCode::StaleNativeSessionCatalog,
9254 "native session catalog revision or cursor is stale",
9255 ),
9256 _ => failure(
9257 NodeFailureCode::BackendOperationFailed,
9258 "native session catalog page failed",
9259 ),
9260 })?;
9261 let entries = self.project_native_session_entries(&route, page.entries);
9262 Ok(crate::protocol::NativeSessionCatalogPage {
9263 window: page.window,
9264 revision: page.revision,
9265 entries,
9266 next_after_selection_id: page.next_after_selection_id,
9267 remaining_count: page.remaining_count,
9268 has_more: page.remaining_count > 0,
9269 })
9270 }
9271
9272 async fn preview_native_session(
9273 &self,
9274 selection: NativeSessionSelection,
9275 message_limit: u16,
9276 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9277 selection
9278 .validate()
9279 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9280 let canonical_root = self.native_session_route_root(&selection.route)?;
9281 let registered_workspace_roots = self.workspace_roots();
9282 let provider = selection.route.provider.clone();
9283 let scope = selection.route.scope;
9284 let catalog_revision = selection.catalog_revision;
9285 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9286 let selection_id = selection.selection_id.clone();
9287 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9288 failure(
9289 NodeFailureCode::InvalidRequest,
9290 "native session preview is not configured",
9291 )
9292 })?;
9293 let _permit = Arc::clone(&self.inspection_slots)
9294 .try_acquire_owned()
9295 .map_err(|_| {
9296 failure(
9297 NodeFailureCode::BackendBusy,
9298 "native session preview capacity is busy",
9299 )
9300 })?;
9301 let preview = run_native_session_catalog_operation(
9302 catalog,
9303 "native session preview",
9304 move |catalog| {
9305 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9306 &provider,
9307 scope,
9308 canonical_root.as_deref().map(Path::new),
9309 ®istered_workspace_roots,
9310 catalog_revision,
9311 recent_cutoff_unix_ms,
9312 &selection_id,
9313 message_limit,
9314 ))
9315 },
9316 )
9317 .await?;
9318 preview
9319 .map(Into::into)
9320 .map_err(native_session_preview_failure)
9321 }
9322
9323 async fn index_native_session(
9324 &self,
9325 selection: NativeSessionSelection,
9326 display_name: String,
9327 ) -> Result<ManagedSessionRecord, NodeFailure> {
9328 selection
9329 .validate()
9330 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9331 if selection.route.scope
9332 != gate4agent_types::NativeSessionCatalogScope::Workspace
9333 {
9334 return Err(failure(
9335 NodeFailureCode::WorkspaceRegistrationRequired,
9336 "register the external project as a workspace before indexing or resuming it",
9337 ));
9338 }
9339 let workspace_id = selection
9340 .route
9341 .workspace_id
9342 .clone()
9343 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9344 let canonical_root = self
9345 .native_session_route_root(&selection.route)?
9346 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9347 let registered_workspace_roots = self.workspace_roots();
9348 let provider = selection.route.provider.clone();
9349 let catalog_revision = selection.catalog_revision;
9350 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9351 let selection_id = selection.selection_id.clone();
9352 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9353 failure(
9354 NodeFailureCode::InvalidRequest,
9355 "native session index is not configured",
9356 )
9357 })?;
9358 let _permit = Arc::clone(&self.inspection_slots)
9359 .try_acquire_owned()
9360 .map_err(|_| {
9361 failure(
9362 NodeFailureCode::BackendBusy,
9363 "native session index capacity is busy",
9364 )
9365 })?;
9366 let resolved = run_native_session_catalog_operation(
9367 catalog,
9368 "native session index resolution",
9369 move |catalog| {
9370 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9371 &provider,
9372 gate4agent_types::NativeSessionCatalogScope::Workspace,
9373 Some(Path::new(&canonical_root)),
9374 ®istered_workspace_roots,
9375 catalog_revision,
9376 recent_cutoff_unix_ms,
9377 &selection_id,
9378 ))
9379 },
9380 )
9381 .await?
9382 .map_err(native_session_preview_failure)?;
9383 self.index_provider_session_with_policy(
9384 workspace_id,
9385 selection.route.provider,
9386 resolved.identity,
9387 display_name,
9388 true,
9389 )
9390 }
9391
9392 async fn preview_native_session_by_session_id(
9393 &self,
9394 workspace_id: WorkspaceId,
9395 provider: AgentId,
9396 session_id: String,
9397 message_limit: u16,
9398 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9399 let canonical_root = self.workspace_root(&workspace_id)?;
9400 let registered_workspace_roots = self.workspace_roots();
9401 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9402 failure(
9403 NodeFailureCode::InvalidRequest,
9404 "native session preview is not configured",
9405 )
9406 })?;
9407 let _permit = Arc::clone(&self.inspection_slots)
9408 .try_acquire_owned()
9409 .map_err(|_| {
9410 failure(
9411 NodeFailureCode::BackendBusy,
9412 "native session preview capacity is busy",
9413 )
9414 })?;
9415 run_native_session_catalog_operation(
9416 catalog,
9417 "native session preview",
9418 move |catalog| {
9419 catalog.preview_session_id_for_workspace(
9420 &provider,
9421 Path::new(&canonical_root),
9422 ®istered_workspace_roots,
9423 &session_id,
9424 message_limit,
9425 )
9426 },
9427 )
9428 .await
9429 }
9430
9431 async fn register_workspace(
9432 &self,
9433 workspace_id: WorkspaceId,
9434 root: String,
9435 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9436 validate_workspace_request_root(&root)?;
9437 let canonicalize_workspace_id = workspace_id.clone();
9438 let workspace = tokio::task::spawn_blocking(move || {
9439 WorkspaceConfig::new(canonicalize_workspace_id, root)
9440 })
9441 .await
9442 .map_err(|error| {
9443 failure(
9444 NodeFailureCode::BackendOperationFailed,
9445 &format!("workspace canonicalization task failed: {error}"),
9446 )
9447 })?
9448 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9449 let transaction = self
9450 .state_transaction
9451 .lock()
9452 .unwrap_or_else(|poisoned| poisoned.into_inner());
9453 let mut workspaces = self
9454 .workspaces
9455 .write()
9456 .unwrap_or_else(|poisoned| poisoned.into_inner());
9457 if workspaces.contains_key(&workspace_id) {
9458 return Err(failure(
9459 NodeFailureCode::DuplicateWorkspaceId,
9460 "workspace ID is already registered",
9461 ));
9462 }
9463 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9464 platform::roots_equal(existing_root, workspace.canonical_root())
9465 }) {
9466 return Err(failure(
9467 NodeFailureCode::DuplicateWorkspaceRoot,
9468 &format!("workspace root is already registered as '{existing_id}'"),
9469 ));
9470 }
9471 {
9472 let records = self
9473 .session_records
9474 .lock()
9475 .unwrap_or_else(|poisoned| poisoned.into_inner());
9476 if let Some(record) = records.records.values().find(|record| {
9477 record.workspace_id == workspace_id
9478 && !record
9479 .canonical_root
9480 .as_utf8()
9481 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9482 }) {
9483 return Err(failure(
9484 NodeFailureCode::SessionRecordConflict,
9485 &format!(
9486 "workspace ID is retained by session record '{}' at another root",
9487 record.record_id,
9488 ),
9489 ));
9490 }
9491 }
9492 let snapshot = WorkspaceSnapshot {
9493 workspace_id: workspace_id.clone(),
9494 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9495 sessions: Vec::new(),
9496 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9497 managed_worktree_profiles: Some(WorktreeProfileInventory {
9498 profiles: Vec::new(),
9499 }),
9500 };
9501 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9502 drop(workspaces);
9503 let previous_records = {
9504 let mut records = self
9505 .session_records
9506 .lock()
9507 .unwrap_or_else(|poisoned| poisoned.into_inner());
9508 let previous = records.records.clone();
9509 for record in records.records.values_mut().filter(|record| {
9510 record.workspace_id == workspace_id
9511 && record
9512 .canonical_root
9513 .as_utf8()
9514 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9515 }) {
9516 if record.active_session.is_none() {
9517 record.state = detached_record_state(record);
9518 }
9519 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9520 record.last_error = None;
9521 }
9522 record.updated_at_unix_ms = unix_time_ms();
9523 }
9524 previous
9525 };
9526 if let Err(error) = self.persist_state_locked() {
9527 self.workspaces
9528 .write()
9529 .unwrap_or_else(|poisoned| poisoned.into_inner())
9530 .remove(&workspace_id);
9531 self.session_records
9532 .lock()
9533 .unwrap_or_else(|poisoned| poisoned.into_inner())
9534 .records = previous_records;
9535 return Err(persistence_failure(error));
9536 }
9537 let restored = self
9538 .session_records
9539 .lock()
9540 .unwrap_or_else(|poisoned| poisoned.into_inner())
9541 .records
9542 .values()
9543 .filter(|record| record.workspace_id == workspace_id)
9544 .cloned()
9545 .collect::<Vec<_>>();
9546 drop(transaction);
9547 self.publish(NodeEvent::WorkspaceAdded {
9548 workspace: snapshot.clone(),
9549 });
9550 for record in restored {
9551 self.publish_record(record);
9552 }
9553 Ok(snapshot)
9554 }
9555
9556 async fn create_standalone_workspace(
9557 &self,
9558 workspace_id: WorkspaceId,
9559 root: String,
9560 initial_branch: Option<String>,
9561 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9562 validate_workspace_request_root(&root)?;
9563 validate_git_revision("initial branch", initial_branch.as_deref())?;
9564 let preflight_workspace_id = workspace_id.clone();
9565 let preflight_root = root.clone();
9566 let canonical_candidate = tokio::task::spawn_blocking(move || {
9567 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9568 })
9569 .await
9570 .map_err(|_| failure(
9571 NodeFailureCode::BackendOperationFailed,
9572 "standalone workspace root preflight task failed",
9573 ))?
9574 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9575 {
9576 let workspaces = self
9577 .workspaces
9578 .read()
9579 .unwrap_or_else(|poisoned| poisoned.into_inner());
9580 if workspaces.contains_key(&workspace_id) {
9581 return Err(failure(
9582 NodeFailureCode::DuplicateWorkspaceId,
9583 "workspace ID is already registered",
9584 ));
9585 }
9586 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9587 platform::roots_equal(existing_root, &canonical_candidate)
9588 }) {
9589 return Err(failure(
9590 NodeFailureCode::DuplicateWorkspaceRoot,
9591 &format!("workspace root is already registered as '{existing_id}'"),
9592 ));
9593 }
9594 }
9595 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9596 .await
9597 .map_err(standalone_workspace_failure)?;
9598 prepared
9599 .verify_for_registration()
9600 .await
9601 .map_err(standalone_workspace_failure)?;
9602 match self
9603 .register_workspace(workspace_id, prepared.root().to_owned())
9604 .await
9605 {
9606 Ok(workspace) => Ok(workspace),
9607 Err(registration_error) => {
9608 if prepared.compensate_registration_failure().await {
9609 Err(registration_error)
9610 } else {
9611 Err(failure(
9612 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9613 "standalone workspace initialization succeeded but registration failed; recovery is required",
9614 ))
9615 }
9616 }
9617 }
9618 }
9619
9620 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9621 let bindings = self
9622 .session_bindings
9623 .lock()
9624 .unwrap_or_else(|poisoned| poisoned.into_inner());
9625 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9626 return Err(failure(
9627 NodeFailureCode::WorkspaceBusy,
9628 "workspace retains a bound session; remove every session before unregistering it",
9629 ));
9630 }
9631 drop(bindings);
9632 let transaction = self
9633 .state_transaction
9634 .lock()
9635 .unwrap_or_else(|poisoned| poisoned.into_inner());
9636 let mut workspaces = self
9637 .workspaces
9638 .write()
9639 .unwrap_or_else(|poisoned| poisoned.into_inner());
9640 if !workspaces.contains_key(workspace_id) {
9641 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9642 }
9643 if workspaces.len() == 1 {
9644 return Err(failure(
9645 NodeFailureCode::LastWorkspace,
9646 "node must retain at least one workspace",
9647 ));
9648 }
9649 let removed_root = workspaces
9650 .remove(workspace_id)
9651 .expect("workspace presence was validated");
9652 drop(workspaces);
9653 let previous_records = {
9654 let mut records = self
9655 .session_records
9656 .lock()
9657 .unwrap_or_else(|poisoned| poisoned.into_inner());
9658 let previous = records.records.clone();
9659 for record in records
9660 .records
9661 .values_mut()
9662 .filter(|record| &record.workspace_id == workspace_id)
9663 {
9664 record.active_session = None;
9665 record.state = ManagedSessionState::Unavailable;
9666 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9667 record.updated_at_unix_ms = unix_time_ms();
9668 }
9669 previous
9670 };
9671 if let Err(error) = self.persist_state_locked() {
9672 self.workspaces
9673 .write()
9674 .unwrap_or_else(|poisoned| poisoned.into_inner())
9675 .insert(workspace_id.clone(), removed_root);
9676 self.session_records
9677 .lock()
9678 .unwrap_or_else(|poisoned| poisoned.into_inner())
9679 .records = previous_records;
9680 return Err(persistence_failure(error));
9681 }
9682 let unavailable = self
9683 .session_records
9684 .lock()
9685 .unwrap_or_else(|poisoned| poisoned.into_inner())
9686 .records
9687 .values()
9688 .filter(|record| &record.workspace_id == workspace_id)
9689 .cloned()
9690 .collect::<Vec<_>>();
9691 drop(transaction);
9692 self.publish(NodeEvent::WorkspaceRemoved {
9693 workspace_id: workspace_id.clone(),
9694 });
9695 for record in unavailable {
9696 self.publish_record(record);
9697 }
9698 Ok(())
9699 }
9700
9701 async fn create_worktree(
9702 &self,
9703 source_workspace_id: WorkspaceId,
9704 workspace_id: WorkspaceId,
9705 target_root: String,
9706 branch: String,
9707 base: Option<String>,
9708 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9709 self.require_worktree_service(&source_workspace_id)?;
9710 validate_workspace_request_root(&target_root)?;
9711 validate_git_revision("worktree base", base.as_deref())?;
9712 let source_root = self.workspace_root(&source_workspace_id)?;
9713 {
9714 let workspaces = self
9715 .workspaces
9716 .read()
9717 .unwrap_or_else(|poisoned| poisoned.into_inner());
9718 if workspaces.contains_key(&workspace_id) {
9719 return Err(failure(
9720 NodeFailureCode::DuplicateWorkspaceId,
9721 "workspace ID is already registered",
9722 ));
9723 }
9724 if let Some((existing_id, _)) = workspaces
9725 .iter()
9726 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9727 {
9728 return Err(failure(
9729 NodeFailureCode::DuplicateWorkspaceRoot,
9730 &format!("workspace root is already registered as '{existing_id}'"),
9731 ));
9732 }
9733 }
9734 let mut worktree = create_git_worktree(
9735 &source_root,
9736 &target_root,
9737 &branch,
9738 base.as_deref(),
9739 )
9740 .await
9741 .map_err(git_worktree_failure)?;
9742 let workspace = match self
9743 .register_workspace(workspace_id, worktree.path.clone())
9744 .await
9745 {
9746 Ok(workspace) => workspace,
9747 Err(registration_error) => {
9748 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9749 return Err(failure(
9750 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9751 "worktree registration failed and compensation requires recovery",
9752 ));
9753 }
9754 return Err(failure(
9755 registration_error.code,
9756 ®istration_error.message,
9757 ));
9758 }
9759 };
9760 worktree.workspace_id = Some(workspace.workspace_id.clone());
9761 Ok((protocol_worktree(worktree), workspace))
9762 }
9763
9764 async fn remove_worktree(
9765 &self,
9766 source_workspace_id: WorkspaceId,
9767 target_root: String,
9768 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9769 self.require_worktree_service(&source_workspace_id)?;
9770 validate_workspace_request_root(&target_root)?;
9771 let source_root = self.workspace_root(&source_workspace_id)?;
9772 let target_root = normalize_worktree_removal_target(&target_root)
9773 .map_err(git_worktree_failure)?;
9774 let target = list_git_worktrees(&source_root)
9775 .await
9776 .map_err(git_worktree_failure)?
9777 .into_iter()
9778 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9779 .ok_or_else(|| {
9780 failure(
9781 NodeFailureCode::WorktreeProtected,
9782 "refusing to remove a path that is not in Git's worktree listing",
9783 )
9784 })?;
9785 let registered_workspace_id = {
9786 let workspaces = self
9787 .workspaces
9788 .read()
9789 .unwrap_or_else(|poisoned| poisoned.into_inner());
9790 let matched = workspaces
9791 .iter()
9792 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9793 .map(|(workspace_id, _)| workspace_id.clone());
9794 if matched.is_some() && workspaces.len() == 1 {
9795 return Err(failure(
9796 NodeFailureCode::LastWorkspace,
9797 "node must retain at least one workspace",
9798 ));
9799 }
9800 matched
9801 };
9802 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9803 let bindings = self
9804 .session_bindings
9805 .lock()
9806 .unwrap_or_else(|poisoned| poisoned.into_inner());
9807 if bindings
9808 .values()
9809 .any(|binding| &binding.workspace_id == workspace_id)
9810 {
9811 return Err(failure(
9812 NodeFailureCode::WorkspaceBusy,
9813 "worktree workspace retains a bound session; remove every session first",
9814 ));
9815 }
9816 }
9817 remove_git_worktree(&source_root, &target.path)
9818 .await
9819 .map_err(git_worktree_failure)?;
9820 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9821 self.unregister_workspace(workspace_id)
9822 .map_err(|error| failure(error.code, &error.message))?;
9823 }
9824 Ok(registered_workspace_id)
9825 }
9826
9827 async fn cleanup_managed_worktree(
9828 &self,
9829 lease_id: &ManagedWorktreeLeaseId,
9830 explicit: bool,
9831 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9832 let lease = self.managed_worktrees.lock()
9833 .unwrap_or_else(|poisoned| poisoned.into_inner())
9834 .get(lease_id)
9835 .cloned()
9836 .ok_or_else(|| failure(
9837 NodeFailureCode::UnknownManagedWorktreeLease,
9838 "managed worktree lease does not exist",
9839 ))?;
9840 if lease.state == ManagedWorktreeLeaseState::Removed {
9841 return Ok(lease.snapshot());
9842 }
9843 if lease.has_holders() {
9844 return Err(failure(
9845 NodeFailureCode::ManagedWorktreeBusy,
9846 "managed worktree lease still has a session or durable-record holder",
9847 ));
9848 }
9849 if self
9850 .materializations
9851 .lock()
9852 .unwrap_or_else(|poisoned| poisoned.into_inner())
9853 .values()
9854 .any(|record| record.managed_lease_id() == Some(lease_id))
9855 {
9856 return Err(failure(
9857 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9858 "managed worktree cleanup is blocked by session environment ownership",
9859 ));
9860 }
9861 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9862 let snapshot = {
9863 let mut registry = self.managed_worktrees.lock()
9864 .unwrap_or_else(|poisoned| poisoned.into_inner());
9865 let current = registry.get_mut(lease_id)
9866 .expect("managed worktree lease remains present");
9867 current.state = ManagedWorktreeLeaseState::Retained;
9868 current.cleanup_failure = None;
9869 current.updated_at_unix_ms = unix_time_ms();
9870 current.snapshot()
9871 };
9872 if self.persist_state().is_err() {
9873 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9874 }
9875 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9876 return Ok(snapshot);
9877 }
9878 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9879 if profile.revision() != &lease.profile_revision {
9880 self.mark_managed_recovery_required(
9881 lease_id,
9882 ManagedWorktreeCleanupFailure::OwnershipConflict,
9883 );
9884 return Err(failure(
9885 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9886 "managed worktree profile revision changed",
9887 ));
9888 }
9889 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9890 self.mark_managed_recovery_required(
9891 lease_id,
9892 ManagedWorktreeCleanupFailure::OwnershipConflict,
9893 );
9894 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9895 })?;
9896 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9897 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9898 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9899 managed_git_worktree_failure(error)
9900 })?;
9901 let target = listed.iter()
9902 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9903 if let Some(target) = target {
9904 if target.is_main || target.is_bare || !exact_owned_worktree(
9905 &lease,
9906 &target.path,
9907 target.branch.as_deref(),
9908 &target.head,
9909 ) {
9910 self.mark_managed_recovery_required(
9911 lease_id,
9912 ManagedWorktreeCleanupFailure::OwnershipConflict,
9913 );
9914 return Err(failure(
9915 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9916 "Git worktree identity no longer matches the managed lease",
9917 ));
9918 }
9919 if target.prunable {
9920 self.mark_managed_cleanup_blocked(
9921 lease_id,
9922 ManagedWorktreeCleanupFailure::Prunable,
9923 );
9924 return Err(failure(
9925 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9926 "managed worktree is prunable and requires recovery",
9927 ));
9928 }
9929 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
9930 let failure_kind = match error.kind {
9931 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
9932 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
9933 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
9934 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
9935 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
9936 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
9937 };
9938 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
9939 self.mark_managed_recovery_required(lease_id, failure_kind);
9940 } else {
9941 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
9942 }
9943 return Err(managed_git_worktree_failure(error));
9944 }
9945 } else {
9946 match std::fs::symlink_metadata(&lease.target_root) {
9947 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
9948 Ok(_) => {
9949 self.mark_managed_recovery_required(
9950 lease_id,
9951 ManagedWorktreeCleanupFailure::OwnershipConflict,
9952 );
9953 return Err(failure(
9954 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9955 "managed target exists but is not owned by Git",
9956 ));
9957 }
9958 Err(_) => {
9959 self.mark_managed_cleanup_blocked(
9960 lease_id,
9961 ManagedWorktreeCleanupFailure::Backend,
9962 );
9963 return Err(failure(
9964 NodeFailureCode::BackendOperationFailed,
9965 "managed target identity could not be inspected",
9966 ));
9967 }
9968 }
9969 }
9970 let registered = self.workspaces.read()
9971 .unwrap_or_else(|poisoned| poisoned.into_inner())
9972 .contains_key(&lease.workspace_id);
9973 if registered {
9974 self.unregister_workspace(&lease.workspace_id)?;
9975 }
9976 let removed = {
9977 self.managed_worktrees.lock()
9978 .unwrap_or_else(|poisoned| poisoned.into_inner())
9979 .tombstone(lease_id, unix_time_ms())
9980 .expect("managed worktree lease remains present during cleanup")
9981 };
9982 self.persist_state().map_err(persistence_failure)?;
9983 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
9984 Ok(removed.snapshot())
9985 }
9986
9987 fn mark_managed_cleanup_blocked(
9988 &self,
9989 lease_id: &ManagedWorktreeLeaseId,
9990 failure_kind: ManagedWorktreeCleanupFailure,
9991 ) {
9992 let snapshot = {
9993 let mut registry = self.managed_worktrees.lock()
9994 .unwrap_or_else(|poisoned| poisoned.into_inner());
9995 let Some(lease) = registry.get_mut(lease_id) else { return };
9996 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
9997 lease.cleanup_failure = Some(failure_kind);
9998 lease.updated_at_unix_ms = unix_time_ms();
9999 lease.snapshot()
10000 };
10001 let _ = self.persist_state();
10002 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10003 }
10004
10005 fn require_worktree_service(
10006 &self,
10007 source_workspace_id: &WorkspaceId,
10008 ) -> Result<(), NodeFailure> {
10009 if self.managed_worktrees.lock()
10010 .unwrap_or_else(|poisoned| poisoned.into_inner())
10011 .lease_for_workspace(source_workspace_id)
10012 .is_some()
10013 {
10014 return Err(failure(
10015 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10016 "managed worktree targets are internal-only workspaces",
10017 ));
10018 }
10019 match self
10020 .worktree_service_modes
10021 .get(source_workspace_id)
10022 .copied()
10023 .unwrap_or(WorktreeServiceMode::Manual)
10024 {
10025 WorktreeServiceMode::Manual => Ok(()),
10026 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10027 NodeFailureCode::UnsupportedSpawnCapability,
10028 "Git worktree service mode is unavailable in this node build",
10029 )),
10030 }
10031 }
10032
10033 fn managed_reservation_conflict(
10034 &self,
10035 workspace_id: Option<&WorkspaceId>,
10036 root: Option<&str>,
10037 ) -> bool {
10038 self.managed_worktrees.lock()
10039 .unwrap_or_else(|poisoned| poisoned.into_inner())
10040 .active_records()
10041 .any(|lease| {
10042 workspace_id.is_some_and(|workspace_id| {
10043 workspace_id == &lease.source_workspace_id
10044 || workspace_id == &lease.workspace_id
10045 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10046 })
10047 }
10048
10049 fn reject_managed_reservation(
10050 &self,
10051 workspace_id: Option<&WorkspaceId>,
10052 root: Option<&str>,
10053 ) -> Result<(), NodeFailure> {
10054 if self.managed_reservation_conflict(workspace_id, root) {
10055 Err(failure(
10056 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10057 "managed worktree ownership may only be mutated through its lease cleanup",
10058 ))
10059 } else {
10060 Ok(())
10061 }
10062 }
10063
10064 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10065 self.validate_address(address)?;
10066 self.workspace_root(&address.workspace_id)
10067 }
10068
10069 fn require_session_runtime_policy(
10103 &self,
10104 address: &SessionAddress,
10105 requirement: ProviderRuntimeRequirement,
10106 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10107 let runtime_policy = {
10108 let bindings = self
10109 .session_bindings
10110 .lock()
10111 .unwrap_or_else(|poisoned| poisoned.into_inner());
10112 let binding = bindings
10113 .get(&address.session.instance_id)
10114 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10115 if binding.workspace_id != address.workspace_id {
10116 return Err(failure(
10117 NodeFailureCode::SessionWorkspaceMismatch,
10118 "session belongs to another workspace",
10119 ));
10120 }
10121 if binding.generation != address.session.generation {
10122 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10123 }
10124 binding.runtime_policy
10125 };
10126 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10127 let snapshot = self.internal_session_snapshot(address)?;
10128 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10129 && snapshot.provider.lead_activity != ProviderActivity::Idle
10130 {
10131 return Err(failure(
10132 NodeFailureCode::TurnInFlight,
10133 "session already has a turn in flight; wait for it to complete before sending another prompt",
10134 ));
10135 }
10136 }
10137 require_policy(runtime_policy, false, false, requirement)
10138 .map(|()| runtime_policy)
10139 .map_err(|_| failure(
10140 NodeFailureCode::UnsupportedCapability,
10141 "session runtime policy does not admit this semantic operation",
10142 ))
10143 }
10144
10145 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10146 let mut bindings = self
10147 .session_bindings
10148 .lock()
10149 .unwrap_or_else(|poisoned| poisoned.into_inner());
10150 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10151 binding.workspace_id == address.workspace_id
10152 && binding.generation == address.session.generation
10153 }) {
10154 let removed = bindings.remove(&address.session.instance_id);
10155 self.clear_terminal_frame_watermark(address);
10156 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10157 return removed;
10158 }
10159 None
10160 }
10161
10162 fn cleanup_session_owned_materialization(
10163 &self,
10164 address: &SessionAddress,
10165 binding: &SessionBinding,
10166 ) -> Result<(), NodeFailure> {
10167 let Some(id) = binding.materialization_id.as_ref() else {
10168 return Ok(());
10169 };
10170 let owner = self
10171 .materializations
10172 .lock()
10173 .unwrap_or_else(|poisoned| poisoned.into_inner())
10174 .get(id)
10175 .map(|record| record.owner().clone());
10176 match owner {
10177 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10178 Some(MaterializationOwner::Session {
10179 incarnation_id,
10180 instance_id,
10181 generation,
10182 }) if incarnation_id == self.incarnation_id
10183 && instance_id == address.session.instance_id
10184 && generation == address.session.generation =>
10185 {
10186 self.cleanup_materialization(id)
10187 }
10188 Some(MaterializationOwner::Session { .. }) => Err(failure(
10189 NodeFailureCode::BackendOperationFailed,
10190 "session environment ownership requires recovery",
10191 )),
10192 }
10193 }
10194
10195 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10196 let mut watermarks = self
10197 .terminal_frame_watermarks
10198 .lock()
10199 .unwrap_or_else(|poisoned| poisoned.into_inner());
10200 if watermarks
10201 .get(&address.session.instance_id)
10202 .is_some_and(|(current, _)| current == address)
10203 {
10204 watermarks.remove(&address.session.instance_id);
10205 }
10206 }
10207
10208 #[cfg(test)]
10209 fn address_for(
10210 &self,
10211 instance_id: AgentInstanceId,
10212 generation: SessionGeneration,
10213 ) -> Option<SessionAddress> {
10214 let binding = self.session_bindings
10215 .lock()
10216 .unwrap_or_else(|poisoned| poisoned.into_inner())
10217 .get(&instance_id)
10218 .cloned()?;
10219 (binding.generation == generation).then_some(SessionAddress {
10220 workspace_id: binding.workspace_id,
10221 session: SessionKey { instance_id, generation },
10222 })
10223 }
10224
10225 fn arm_resume(
10226 &self,
10227 address: &SessionAddress,
10228 command_id: CommandId,
10229 runtime_policy: ProviderRuntimePolicy,
10230 ) -> Result<(), NodeFailure> {
10231 let mut bindings = self
10232 .session_bindings
10233 .lock()
10234 .unwrap_or_else(|poisoned| poisoned.into_inner());
10235 let binding = bindings
10236 .get_mut(&address.session.instance_id)
10237 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10238 if binding.workspace_id != address.workspace_id {
10239 return Err(failure(
10240 NodeFailureCode::SessionWorkspaceMismatch,
10241 "session belongs to another workspace",
10242 ));
10243 }
10244 if binding.generation != address.session.generation {
10245 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10246 }
10247 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10248 Ok(())
10249 }
10250
10251 fn clear_armed_resume(&self, address: &SessionAddress) {
10252 let mut bindings = self
10253 .session_bindings
10254 .lock()
10255 .unwrap_or_else(|poisoned| poisoned.into_inner());
10256 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10257 if binding.workspace_id == address.workspace_id
10258 && binding.generation == address.session.generation
10259 {
10260 binding.pending_resume = None;
10261 }
10262 }
10263 }
10264
10265 fn publish_control(&self, event: ControlEvent) {
10266 let revokes_harness_mcp = matches!(
10267 &event.event,
10268 ControlEventKind::Exited { .. }
10269 | ControlEventKind::Failed { .. }
10270 | ControlEventKind::Removed
10271 );
10272 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10273 let mut bindings = self
10274 .session_bindings
10275 .lock()
10276 .unwrap_or_else(|poisoned| poisoned.into_inner());
10277 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10278 return;
10279 };
10280 let previous_address = SessionAddress {
10281 workspace_id: binding.workspace_id.clone(),
10282 session: SessionKey {
10283 instance_id: event.instance_id,
10284 generation: binding.generation,
10285 },
10286 };
10287 let mut resume_command_rejected = false;
10288 if binding.generation == event.generation {
10289 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10290 let rejected_resume = matches!(
10291 &event.event,
10292 ControlEventKind::CommandRejected { .. }
10293 ) && event.command_id == Some(pending_command_id);
10294 let resume_failed = matches!(
10295 &event.event,
10296 ControlEventKind::ResumeDenied { .. }
10297 | ControlEventKind::ResumeFailed { .. }
10298 );
10299 if pending_generation == binding.generation
10300 && (rejected_resume || resume_failed)
10301 {
10302 resume_command_rejected = rejected_resume;
10303 binding.pending_resume = None;
10304 }
10305 }
10306 } else {
10307 let expected = binding.generation.0.checked_add(1);
10308 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10309 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10310 generation == binding.generation
10311 })
10312 || expected != Some(event.generation.0)
10313 || !authorized
10314 {
10315 return;
10316 }
10317 let (_, _, runtime_policy) = binding
10318 .pending_resume
10319 .expect("authorized resume retains its pending runtime policy");
10320 binding.generation = event.generation;
10321 binding.runtime_policy = runtime_policy;
10322 binding.pending_resume = None;
10323 }
10324 (
10325 SessionAddress {
10326 workspace_id: binding.workspace_id.clone(),
10327 session: SessionKey {
10328 instance_id: event.instance_id,
10329 generation: event.generation,
10330 },
10331 },
10332 (previous_address.session.generation != event.generation)
10333 .then_some(previous_address),
10334 binding.record_id.clone(),
10335 binding.runtime_policy.provider_session_identity,
10336 resume_command_rejected,
10337 )
10338 };
10339 if let Some(previous_address) = previous_address.as_ref() {
10340 self.clear_terminal_frame_watermark(previous_address);
10341 }
10342 if let Some(record_id) = record_id {
10343 self.reconcile_managed_record(
10344 &record_id,
10345 &address,
10346 &event,
10347 identity_admitted,
10348 resume_command_rejected,
10349 );
10350 }
10351 match &event.event {
10359 ControlEventKind::Running { process_id } => {
10360 #[cfg(unix)]
10361 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10362 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10363 }
10364 tracing::info!(
10365 node_id = %self.node_id,
10366 workspace_id = %address.workspace_id,
10367 session = ?address.session,
10368 pid = ?process_id,
10369 "session process is running",
10370 );
10371 }
10372 ControlEventKind::Exited { exit_code, forced } => {
10373 #[cfg(unix)]
10374 if let Some(proxy) = self.signer_proxy.get().cloned() {
10375 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10376 if proxy.has_session(&key) {
10377 std::thread::spawn(move || proxy.release(&key));
10379 }
10380 }
10381 tracing::info!(
10382 node_id = %self.node_id,
10383 workspace_id = %address.workspace_id,
10384 session = ?address.session,
10385 exit_code = ?exit_code,
10386 forced = forced,
10387 "session process exited",
10388 );
10389 }
10390 ControlEventKind::Failed { message } => {
10391 tracing::warn!(
10392 node_id = %self.node_id,
10393 workspace_id = %address.workspace_id,
10394 session = ?address.session,
10395 cause = %message,
10396 "session failed",
10397 );
10398 }
10399 ControlEventKind::CommandRejected { message } => {
10400 tracing::warn!(
10401 node_id = %self.node_id,
10402 workspace_id = %address.workspace_id,
10403 session = ?address.session,
10404 cause = %message,
10405 "session command rejected",
10406 );
10407 }
10408 ControlEventKind::Removed => {
10409 tracing::info!(
10410 node_id = %self.node_id,
10411 workspace_id = %address.workspace_id,
10412 session = ?address.session,
10413 "session removed",
10414 );
10415 }
10416 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10417 tracing::info!(
10418 node_id = %self.node_id,
10419 workspace_id = %address.workspace_id,
10420 session = ?address.session,
10421 provider_session = ?provider_session,
10422 "session resume authorized",
10423 );
10424 }
10425 ControlEventKind::Resumed { session: provider_session, process_id } => {
10426 tracing::info!(
10427 node_id = %self.node_id,
10428 workspace_id = %address.workspace_id,
10429 session = ?address.session,
10430 provider_session = ?provider_session,
10431 pid = ?process_id,
10432 "session resumed",
10433 );
10434 }
10435 ControlEventKind::ResumeDenied { reason } => {
10436 tracing::warn!(
10437 node_id = %self.node_id,
10438 workspace_id = %address.workspace_id,
10439 session = ?address.session,
10440 cause = %reason,
10441 "session resume denied",
10442 );
10443 }
10444 ControlEventKind::ResumeFailed { message } => {
10445 tracing::warn!(
10446 node_id = %self.node_id,
10447 workspace_id = %address.workspace_id,
10448 session = ?address.session,
10449 cause = %message,
10450 "session resume failed",
10451 );
10452 }
10453 _ => {}
10454 }
10455 self.apply_pending_acp_options(&address, &event);
10456 let stream_chunk = agent_stream_chunk(&event);
10457 self.publish(NodeEvent::Control {
10458 address: address.clone(),
10459 event,
10460 });
10461 if let Some(chunk) = stream_chunk {
10462 self.publish_agent_stream_chunk(address.clone(), chunk);
10463 }
10464 if revokes_harness_mcp {
10465 SessionLifecycleHook::on_session_ended(self, &address);
10466 }
10467 }
10468
10469 fn reconcile_managed_record(
10470 &self,
10471 record_id: &SessionRecordId,
10472 address: &SessionAddress,
10473 event: &ControlEvent,
10474 identity_admitted: bool,
10475 resume_command_rejected: bool,
10476 ) {
10477 let _transaction = self
10478 .state_transaction
10479 .lock()
10480 .unwrap_or_else(|poisoned| poisoned.into_inner());
10481 let observed_identity = match &event.event {
10482 ControlEventKind::ProviderEvent {
10483 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10484 ..
10485 } if identity_admitted => Some(identity.clone()),
10486 _ => None,
10487 };
10488 let identity_was_observed = observed_identity.is_some();
10489 let replacement_id = observed_identity
10490 .as_ref()
10491 .and_then(|_| self.allocate_record_id().ok());
10492 let mut upserts = Vec::new();
10493 let mut removals = Vec::new();
10494 let mut rebind = None;
10495 let original_records;
10496 let original_materializations = self
10497 .materializations
10498 .lock()
10499 .unwrap_or_else(|poisoned| poisoned.into_inner())
10500 .clone();
10501 {
10502 let mut records = self
10503 .session_records
10504 .lock()
10505 .unwrap_or_else(|poisoned| poisoned.into_inner());
10506 original_records = records.records.clone();
10507 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10508 return;
10509 };
10510 if let Some(identity) = observed_identity {
10511 let existing_id = records
10512 .records
10513 .values()
10514 .find(|record| {
10515 record.record_id != *record_id
10516 && record.provider_session.as_ref().is_some_and(|record_identity| {
10517 session_registry::same_provider_session(
10518 &record.provider,
10519 record_identity,
10520 ¤t_snapshot.provider,
10521 &identity,
10522 )
10523 })
10524 })
10525 .map(|record| record.record_id.clone());
10526 if let Some(existing_id) = existing_id {
10527 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10528 record.mode == current_snapshot.mode
10529 && record.workspace_id == current_snapshot.workspace_id
10530 && record.environment_profile == current_snapshot.environment_profile
10531 && record.bundle == current_snapshot.bundle
10532 && record.context_id == current_snapshot.context_id
10533 && record.context == current_snapshot.context
10534 && record
10535 .canonical_root
10536 .as_utf8()
10537 .zip(current_snapshot.canonical_root.as_utf8())
10538 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10539 });
10540 if !scope_matches {
10541 if let Some(current) = records.records.get_mut(record_id) {
10542 current.state = ManagedSessionState::Unavailable;
10543 current.last_error =
10544 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10545 current.updated_at_unix_ms = unix_time_ms();
10546 upserts.push(current.clone());
10547 }
10548 } else {
10549 let existing_is_active = records
10550 .records
10551 .get(&existing_id)
10552 .is_some_and(|record| record.active_session.is_some());
10553 if existing_is_active {
10554 if let Some(current) = records.records.get_mut(record_id) {
10555 current.state = ManagedSessionState::Unavailable;
10556 current.last_error =
10557 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10558 current.updated_at_unix_ms = unix_time_ms();
10559 upserts.push(current.clone());
10560 }
10561 } else {
10562 if current_snapshot.provider_session.is_some() {
10563 if let Some(current) = records.records.get_mut(record_id) {
10564 current.active_session = None;
10565 current.state = ManagedSessionState::Dormant;
10566 current.updated_at_unix_ms = unix_time_ms();
10567 upserts.push(current.clone());
10568 }
10569 } else {
10570 records.records.remove(record_id);
10571 removals.push(record_id.clone());
10572 }
10573 if let Some(existing) = records.records.get_mut(&existing_id) {
10574 existing.provider_session = Some(identity.clone());
10575 existing.active_session = Some(address.clone());
10576 existing.state = ManagedSessionState::Live;
10577 existing.updated_at_unix_ms = unix_time_ms();
10578 existing.last_error = None;
10579 upserts.push(existing.clone());
10580 }
10581 rebind = Some(existing_id);
10582 }
10583 }
10584 } else if current_snapshot
10585 .provider_session
10586 .as_ref()
10587 .is_some_and(|current| {
10588 !session_registry::same_provider_session(
10589 ¤t_snapshot.provider,
10590 current,
10591 ¤t_snapshot.provider,
10592 &identity,
10593 )
10594 })
10595 {
10596 if let Some(current) = records.records.get_mut(record_id) {
10597 current.active_session = None;
10598 if current.bundle.is_some() || current.context.is_some() {
10599 let had_bundle = current.bundle.is_some();
10600 current.provider_session = None;
10601 current.bundle = None;
10602 current.context_id = None;
10603 current.context = None;
10604 current.state = ManagedSessionState::Unavailable;
10605 current.last_error = Some(if had_bundle {
10606 "bundle-unavailable".to_owned()
10607 } else {
10608 "context-unavailable".to_owned()
10609 });
10610 } else {
10611 current.state = ManagedSessionState::Dormant;
10612 }
10613 current.updated_at_unix_ms = unix_time_ms();
10614 upserts.push(current.clone());
10615 }
10616 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10617 if let Some(current) = records.records.get_mut(record_id) {
10618 current.state = ManagedSessionState::Unavailable;
10619 current.last_error =
10620 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10621 current.updated_at_unix_ms = unix_time_ms();
10622 upserts.push(current.clone());
10623 }
10624 } else if let Some(new_record_id) = replacement_id {
10625 let now = unix_time_ms();
10626 let replacement = ManagedSessionRecord {
10627 record_id: new_record_id.clone(),
10628 display_name: format!(
10629 "{} #{}",
10630 current_snapshot.provider.as_str(),
10631 records
10632 .records
10633 .values()
10634 .filter(|record| {
10635 record.provider == current_snapshot.provider
10636 })
10637 .count()
10638 .saturating_add(1),
10639 ),
10640 provider: current_snapshot.provider.clone(),
10641 mode: current_snapshot.mode,
10642 state: ManagedSessionState::Live,
10643 workspace_id: current_snapshot.workspace_id,
10644 canonical_root: current_snapshot.canonical_root,
10645 provider_session: Some(identity),
10646 active_session: Some(address.clone()),
10647 environment_profile: current_snapshot.environment_profile,
10648 bundle: current_snapshot.bundle,
10649 context_id: current_snapshot.context_id,
10650 context: current_snapshot.context,
10651 exported_context: None,
10657 task_binding: current_snapshot.task_binding,
10658 created_at_unix_ms: now,
10659 updated_at_unix_ms: now,
10660 last_error: None,
10661 };
10662 records
10663 .records
10664 .insert(new_record_id.clone(), replacement.clone());
10665 upserts.push(replacement);
10666 rebind = Some(new_record_id);
10667 } else if let Some(current) = records.records.get_mut(record_id) {
10668 current.state = ManagedSessionState::Unavailable;
10669 current.last_error =
10670 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10671 upserts.push(current.clone());
10672 }
10673 } else if let Some(current) = records.records.get_mut(record_id) {
10674 current.provider_session = Some(identity);
10675 current.active_session = Some(address.clone());
10676 current.state = ManagedSessionState::Live;
10677 current.updated_at_unix_ms = unix_time_ms();
10678 current.last_error = None;
10679 upserts.push(current.clone());
10680 }
10681 } else if let Some(current) = records.records.get_mut(record_id) {
10682 if resume_command_rejected {
10683 current.active_session = None;
10684 current.state = detached_record_state(current);
10685 current.updated_at_unix_ms = unix_time_ms();
10686 current.last_error =
10687 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10688 upserts.push(current.clone());
10689 } else {
10690 match &event.event {
10691 ControlEventKind::Running { .. }
10692 | ControlEventKind::ResumeAuthorized { .. }
10693 | ControlEventKind::Resumed { .. } => {
10694 current.active_session = Some(address.clone());
10695 current.state = active_record_state(
10696 identity_admitted,
10697 current.provider_session.is_some(),
10698 );
10699 current.updated_at_unix_ms = unix_time_ms();
10700 current.last_error = None;
10701 upserts.push(current.clone());
10702 }
10703 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10704 current.active_session = None;
10705 current.state = detached_record_state(current);
10706 current.updated_at_unix_ms = unix_time_ms();
10707 upserts.push(current.clone());
10708 }
10709 ControlEventKind::Failed { message }
10710 | ControlEventKind::ResumeFailed { message } => {
10711 current.active_session = None;
10712 current.state = detached_record_state(current);
10713 current.updated_at_unix_ms = unix_time_ms();
10714 current.last_error = Some(
10715 session_registry::sanitized_record_error_summary(message),
10716 );
10717 upserts.push(current.clone());
10718 }
10719 ControlEventKind::ResumeDenied { reason } => {
10720 current.active_session = None;
10721 current.state = detached_record_state(current);
10722 current.updated_at_unix_ms = unix_time_ms();
10723 current.last_error = Some(
10724 session_registry::sanitized_record_error_summary(reason),
10725 );
10726 upserts.push(current.clone());
10727 }
10728 _ => {}
10729 }
10730 }
10731 }
10732 }
10733 let materialization_owner_record = identity_was_observed.then(|| {
10734 rebind.clone().unwrap_or_else(|| record_id.clone())
10735 }).and_then(|candidate| {
10736 self.session_records
10737 .lock()
10738 .unwrap_or_else(|poisoned| poisoned.into_inner())
10739 .records
10740 .get(&candidate)
10741 .filter(|record| {
10742 record.provider_session.is_some()
10743 && record.active_session.as_ref() == Some(address)
10744 })
10745 .map(|_| candidate)
10746 });
10747 if let Some(owner_record_id) = materialization_owner_record {
10748 let materialization_id = self
10749 .session_bindings
10750 .lock()
10751 .unwrap_or_else(|poisoned| poisoned.into_inner())
10752 .get(&address.session.instance_id)
10753 .and_then(|binding| binding.materialization_id.clone());
10754 if let Some(materialization_id) = materialization_id {
10755 let mut transfer_failed = false;
10756 {
10757 let mut materializations = self
10758 .materializations
10759 .lock()
10760 .unwrap_or_else(|poisoned| poisoned.into_inner());
10761 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10762 match ownership.owner() {
10763 MaterializationOwner::Session {
10764 incarnation_id,
10765 instance_id,
10766 generation,
10767 } if *incarnation_id == self.incarnation_id
10768 && *instance_id == address.session.instance_id
10769 && *generation == address.session.generation =>
10770 {
10771 transfer_failed = ownership
10772 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10773 .is_err();
10774 }
10775 MaterializationOwner::Record { record_id }
10776 if record_id == &owner_record_id => {}
10777 MaterializationOwner::Record { record_id } => {
10778 let expected = record_id.clone();
10779 transfer_failed = ownership
10780 .transfer_record_owner(
10781 &expected,
10782 owner_record_id.clone(),
10783 unix_time_ms(),
10784 )
10785 .is_err();
10786 }
10787 _ => transfer_failed = true,
10788 }
10789 if transfer_failed {
10790 let _ = ownership.mark_recovery_required(unix_time_ms());
10791 }
10792 } else {
10793 transfer_failed = true;
10794 }
10795 }
10796 if transfer_failed {
10797 if let Some(record) = self
10798 .session_records
10799 .lock()
10800 .unwrap_or_else(|poisoned| poisoned.into_inner())
10801 .records
10802 .get_mut(&owner_record_id)
10803 {
10804 record.state = ManagedSessionState::Unavailable;
10805 record.last_error = Some("environment-profile-unavailable".to_owned());
10806 record.updated_at_unix_ms = unix_time_ms();
10807 upserts.push(record.clone());
10808 }
10809 }
10810 }
10811 }
10812 if let Some(new_record_id) = rebind.clone() {
10813 if let Some(binding) = self
10814 .session_bindings
10815 .lock()
10816 .unwrap_or_else(|poisoned| poisoned.into_inner())
10817 .get_mut(&address.session.instance_id)
10818 {
10819 binding.record_id = Some(new_record_id);
10820 }
10821 }
10822 if upserts.is_empty() && removals.is_empty() {
10823 return;
10824 }
10825 if self.persist_state_locked().is_err() {
10826 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10827 let mut records = self
10828 .session_records
10829 .lock()
10830 .unwrap_or_else(|poisoned| poisoned.into_inner());
10831 records.records = original_records;
10832 *self
10833 .materializations
10834 .lock()
10835 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10836 if let Some(current) = records.records.get_mut(record_id) {
10837 current.state = ManagedSessionState::Unavailable;
10838 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10839 current.updated_at_unix_ms = unix_time_ms();
10840 upserts = vec![current.clone()];
10841 } else {
10842 upserts.clear();
10843 }
10844 removals.clear();
10845 drop(records);
10846 if let Some(binding) = self
10847 .session_bindings
10848 .lock()
10849 .unwrap_or_else(|poisoned| poisoned.into_inner())
10850 .get_mut(&address.session.instance_id)
10851 {
10852 binding.record_id = Some(record_id.clone());
10853 }
10854 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10855 }
10856 for record in upserts {
10857 self.publish_record(record);
10858 }
10859 for record_id in removals {
10860 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10861 }
10862 }
10863
10864 fn snapshot(&self) -> NodeSnapshot {
10865 let control = self.handle.snapshot();
10866 let bindings = self
10867 .session_bindings
10868 .lock()
10869 .unwrap_or_else(|poisoned| poisoned.into_inner())
10870 .clone();
10871 let workspace_roots = self
10872 .workspaces
10873 .read()
10874 .unwrap_or_else(|poisoned| poisoned.into_inner())
10875 .clone();
10876 let environment_profiles = self
10877 .environment_profiles
10878 .read()
10879 .unwrap_or_else(|poisoned| poisoned.into_inner());
10880 let mut workspaces = workspace_roots
10881 .iter()
10882 .map(|(workspace_id, canonical_root)| {
10883 (
10884 workspace_id.clone(),
10885 WorkspaceSnapshot {
10886 workspace_id: workspace_id.clone(),
10887 canonical_root: opaque_windows_path(canonical_root.clone()),
10888 sessions: Vec::new(),
10889 worktree_service_mode: Some(
10890 self.worktree_service_modes
10891 .get(workspace_id)
10892 .copied()
10893 .unwrap_or(WorktreeServiceMode::Manual),
10894 ),
10895 managed_worktree_profiles: Some(WorktreeProfileInventory {
10896 profiles: self
10897 .managed_worktree_profiles
10898 .get(workspace_id)
10899 .into_iter()
10900 .flat_map(BTreeMap::values)
10901 .map(|profile| ManagedWorktreeProfileSummary {
10902 id: profile.profile_id().clone(),
10903 revision: profile.revision().clone(),
10904 retention: profile.retention(),
10905 })
10906 .collect(),
10907 }),
10908 },
10909 )
10910 })
10911 .collect::<BTreeMap<_, _>>();
10912 let mut agent_progress = Vec::new();
10913 for session in &control.sessions {
10914 let Some(binding) = bindings.get(&session.instance_id) else {
10915 continue;
10916 };
10917 if binding.generation != session.generation {
10918 continue;
10919 }
10920 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
10921 let address = SessionAddress {
10922 workspace_id: binding.workspace_id.clone(),
10923 session: SessionKey {
10924 instance_id: session.instance_id,
10925 generation: session.generation,
10926 },
10927 };
10928 if let Some(progress) =
10929 agent_progress_from_provider_snapshot(address, &session.provider)
10930 {
10931 agent_progress.push(progress);
10932 }
10933 workspace.sessions.push(session.clone());
10934 }
10935 }
10936 NodeSnapshot {
10937 node_id: self.node_id.clone(),
10938 enabled_providers: self.enabled_providers.clone(),
10939 provider_runtime_statuses: {
10940 let updates = self
10941 .provider_runtime_status_updates
10942 .read()
10943 .unwrap_or_else(|poisoned| poisoned.into_inner());
10944 ProviderRuntimeStatuses::new(
10945 self.provider_runtime_statuses
10946 .iter()
10947 .filter(|status| !updates.contains_key(status.provider()))
10948 .cloned()
10949 .chain(updates.values().cloned()),
10950 )
10951 .expect("refreshed provider runtime inventory remains bounded and unique")
10952 },
10953 workspaces: workspaces.into_values().collect(),
10954 session_records: self
10955 .session_records
10956 .lock()
10957 .unwrap_or_else(|poisoned| poisoned.into_inner())
10958 .records
10959 .values()
10960 .cloned()
10961 .collect(),
10962 managed_worktrees: self.managed_worktrees
10963 .lock()
10964 .unwrap_or_else(|poisoned| poisoned.into_inner())
10965 .snapshots(),
10966 launch_inventory: Some(LaunchInventory {
10967 spawn_profiles: Some(
10968 self.spawn_profiles
10969 .iter()
10970 .filter_map(|profile| {
10971 resolved_spawn_profile_summary(profile, &environment_profiles)
10972 })
10973 .collect(),
10974 ),
10975 bundles: Some(
10976 self.bundle_catalog
10977 .read()
10978 .unwrap_or_else(|poisoned| poisoned.into_inner())
10979 .iter()
10980 .map(NodeBundle::receipt)
10981 .collect(),
10982 ),
10983 network_allowlists: {
10986 let catalog = self
10987 .network_allowlist_catalog
10988 .read()
10989 .unwrap_or_else(|poisoned| poisoned.into_inner());
10990 if catalog.is_empty() {
10991 None
10992 } else {
10993 Some(catalog.ids().cloned().collect())
10994 }
10995 },
10996 }),
10997 agent_progress,
10998 }
10999 }
11000
11001 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11002 assert!(
11003 !matches!(
11004 &event,
11005 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11006 ),
11007 "terminal frame and agent stream events must use their own dedicated live channel",
11008 );
11009 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11010 let sequence = history
11011 .last_sequence
11012 .checked_add(1)
11013 .expect("node event sequence exhausted");
11014 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11015 if let Some(evicted) = history.events.pop_front() {
11016 history.replay_floor_sequence = evicted
11017 .sequence
11018 .checked_add(1)
11019 .expect("node replay floor sequence exhausted");
11020 history.removed_record_providers.remove(&evicted.sequence);
11021 }
11022 }
11023 match &event {
11024 NodeEvent::HarnessMcpReadCall { .. } => {
11025 panic!("transient harness MCP calls must not enter durable event history")
11026 }
11027 NodeEvent::SessionRecordUpserted { record } => {
11028 history
11029 .record_providers
11030 .insert(record.record_id.clone(), record.provider.clone());
11031 }
11032 NodeEvent::SessionRecordRemoved { record_id } => {
11033 if let Some(provider) = history.record_providers.remove(record_id) {
11034 history.removed_record_providers.insert(sequence, provider);
11035 }
11036 }
11037 NodeEvent::ManagedWorktreeUpserted { .. }
11038 | NodeEvent::ManagedWorktreeRemoved { .. }
11039 | NodeEvent::Control { .. }
11040 | NodeEvent::ControllerChanged { .. }
11041 | NodeEvent::SessionRecordHistorySummarized { .. }
11042 | NodeEvent::WorkspaceAdded { .. }
11043 | NodeEvent::WorkspaceRemoved { .. }
11044 | NodeEvent::ResyncRequired { .. }
11045 | NodeEvent::AgentStream { .. }
11046 | NodeEvent::TerminalFrame { .. } => {}
11047 }
11048 let envelope = NodeEventEnvelope { sequence, event };
11049 history.events.push_back(envelope.clone());
11050 history.last_sequence = sequence;
11051 let _ = self.event_tx.send(envelope.clone());
11052 drop(history);
11053 envelope
11054 }
11055
11056 fn publish_transient(&self, event: NodeEvent) -> bool {
11067 assert!(event.requires_harness_mcp_proxy_capability());
11068 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11069 let envelope = NodeEventEnvelope { sequence: 0, event };
11070 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11071 Ok(_) => true,
11072 Err(broadcast::error::SendError(undelivered)) => {
11073 if let Some(NodeEventEnvelope {
11074 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11075 ..
11076 }) = undelivered.first() {
11077 tracing::warn!(
11083 call_id = call_id.as_str(),
11084 content_type = ?request.content_type,
11085 cause = "no-subscribers",
11086 "harness MCP proxy event could not be published",
11087 );
11088 }
11089 false
11090 }
11091 }
11092 }
11093
11094 fn publish_terminal_frames(&self) {
11095 let snapshot = self.handle.snapshot();
11096 let bindings = self
11097 .session_bindings
11098 .lock()
11099 .unwrap_or_else(|poisoned| poisoned.into_inner());
11100 let candidates = snapshot
11101 .sessions
11102 .iter()
11103 .filter_map(|session| {
11104 let binding = bindings.get(&session.instance_id)?;
11105 if binding.generation != session.generation {
11106 return None;
11107 }
11108 let frame = session.terminal_frame.clone()?;
11109 crate::session_signals::feed(
11110 binding.workspace_id.as_str(),
11111 session.instance_id.0,
11112 session.agent_id.as_str(),
11113 &frame.contents,
11114 );
11115 Some((
11116 SessionAddress {
11117 workspace_id: binding.workspace_id.clone(),
11118 session: SessionKey {
11119 instance_id: session.instance_id,
11120 generation: session.generation,
11121 },
11122 },
11123 frame,
11124 ))
11125 })
11126 .collect();
11127 drop(bindings);
11128 self.publish_terminal_frame_candidates(candidates);
11129 }
11130
11131 fn publish_terminal_frame_candidates(
11132 &self,
11133 candidates: Vec<(SessionAddress, TerminalFrame)>,
11134 ) {
11135 let mut watermarks = self
11136 .terminal_frame_watermarks
11137 .lock()
11138 .unwrap_or_else(|poisoned| poisoned.into_inner());
11139 let mut events = Vec::with_capacity(candidates.len());
11140 for (address, frame) in candidates {
11141 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11142 let advanced = watermarks
11143 .get(&address.session.instance_id)
11144 .map_or(true, |(current_address, sequence)| {
11145 current_address != &address || frame.sequence > *sequence
11146 });
11147 if !advanced {
11148 continue;
11149 }
11150 if is_first_frame {
11151 if self.controller_state().is_none() {
11165 tracing::warn!(
11166 node_id = %self.node_id,
11167 workspace_id = %address.workspace_id,
11168 session = ?address.session,
11169 terminal_size = ?frame.size,
11170 "session producing output with no controller attached",
11171 );
11172 } else {
11173 tracing::info!(
11174 node_id = %self.node_id,
11175 workspace_id = %address.workspace_id,
11176 session = ?address.session,
11177 terminal_size = ?frame.size,
11178 "session produced its first output",
11179 );
11180 }
11181 }
11182 watermarks.insert(
11183 address.session.instance_id,
11184 (address.clone(), frame.sequence),
11185 );
11186 if let Some((at, reported)) = self
11187 .pty_input_marks
11188 .lock()
11189 .unwrap_or_else(|poisoned| poisoned.into_inner())
11190 .get_mut(&address.session.instance_id)
11191 {
11192 if !*reported && at.elapsed() < Duration::from_secs(5) {
11194 *reported = true;
11195 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");
11196 }
11197 }
11198 events.push(NodeEvent::TerminalFrame { address, frame });
11199 }
11200 drop(watermarks);
11201 if events.is_empty() {
11202 return;
11203 }
11204 let mut history = self
11205 .history
11206 .lock()
11207 .unwrap_or_else(|poisoned| poisoned.into_inner());
11208 let mut envelopes = Vec::with_capacity(events.len());
11209 for event in events {
11210 let sequence = history
11211 .last_sequence
11212 .checked_add(1)
11213 .expect("node event sequence exhausted");
11214 history.last_sequence = sequence;
11215 envelopes.push(NodeEventEnvelope { sequence, event });
11216 }
11217 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11218 drop(history);
11219 }
11220
11221 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11230 let mut history = self
11231 .history
11232 .lock()
11233 .unwrap_or_else(|poisoned| poisoned.into_inner());
11234 let sequence = history
11235 .last_sequence
11236 .checked_add(1)
11237 .expect("node event sequence exhausted");
11238 history.last_sequence = sequence;
11239 drop(history);
11240 tracing::debug!(
11248 source_sequence = chunk.source_sequence,
11249 kind = agent_stream_chunk_kind_label(&chunk.kind),
11250 envelope_sequence = sequence,
11251 "publishing agent stream chunk",
11252 );
11253 let envelope = NodeEventEnvelope {
11254 sequence,
11255 event: NodeEvent::AgentStream { address, chunk },
11256 };
11257 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11258 }
11259
11260 fn current_sequence(&self) -> u64 {
11261 self.history
11262 .lock()
11263 .unwrap_or_else(|poisoned| poisoned.into_inner())
11264 .last_sequence
11265 }
11266
11267 fn controller_state(&self) -> Option<ControllerState> {
11268 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11269 let lease = controller.as_ref().copied()?;
11270 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11271 if remaining.is_zero() {
11272 *controller = None;
11273 return None;
11274 }
11275 Some(ControllerState {
11276 connection_id: lease.connection_id,
11277 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11278 })
11279 }
11280
11281 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11282 if role != ClientRole::Operator {
11283 tracing::warn!(
11284 node_id = %self.node_id,
11285 connection_id,
11286 "controller lease rejected: observer connections cannot acquire control",
11287 );
11288 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11289 }
11290 let lease_ms = if lease_ms == 0 {
11291 DEFAULT_CONTROLLER_LEASE_MS
11292 } else {
11293 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11294 };
11295 let now = Instant::now();
11296 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11297 if let Some(active) = controller.as_ref() {
11298 if active.connection_id != connection_id && active.expires_at > now {
11299 tracing::warn!(
11300 node_id = %self.node_id,
11301 connection_id,
11302 holder_connection_id = active.connection_id,
11303 "controller lease rejected: another operator holds it",
11304 );
11305 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11306 }
11307 }
11308 let is_new_attach = match controller.as_ref() {
11315 Some(active) => active.connection_id != connection_id,
11316 None => true,
11317 };
11318 *controller = Some(ControllerLease {
11319 connection_id,
11320 expires_at: now + Duration::from_millis(lease_ms),
11321 });
11322 drop(controller);
11323 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11324 if is_new_attach {
11325 tracing::info!(
11326 node_id = %self.node_id,
11327 connection_id,
11328 lease_ms,
11329 "controller lease acquired",
11330 );
11331 } else {
11332 tracing::debug!(
11333 node_id = %self.node_id,
11334 connection_id,
11335 lease_ms,
11336 "controller lease renewed",
11337 );
11338 }
11339 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11340 Ok(state)
11341 }
11342
11343 fn release_controller(&self, connection_id: u64) -> bool {
11344 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11345 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11346 if released {
11347 *controller = None;
11348 }
11349 drop(controller);
11350 if released {
11351 tracing::info!(
11352 node_id = %self.node_id,
11353 connection_id,
11354 "controller lease released",
11355 );
11356 self.publish(NodeEvent::ControllerChanged { controller: None });
11357 }
11358 released
11359 }
11360
11361 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11370 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11371 return;
11372 };
11373 let id = address.session.instance_id;
11374 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11375 return;
11376 };
11377 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11378 .iter()
11379 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11380 .collect();
11381 let (writes, set_model, effort_gap) =
11382 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11383 if set_model {
11384 if let Some(model_id) = model.clone() {
11385 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11386 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11387 }
11388 }
11389 }
11390 for (option_id, value_json) in writes {
11391 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11392 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11393 }
11394 }
11395 if effort_gap {
11396 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11397 }
11398 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11399 }
11400
11401 fn mark_spawn_provisional(&self, address: SessionAddress) {
11402 let id = address.session.instance_id;
11403 self.provisional_spawns
11404 .lock()
11405 .unwrap_or_else(|poisoned| poisoned.into_inner())
11406 .insert(id, (address, Instant::now()));
11407 tracing::info!(
11408 node_id = %self.node_id,
11409 instance_id = id.0,
11410 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11411 "draft spawn; waiting for HQ view",
11412 );
11413 }
11414
11415 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11416 let removed = self
11417 .provisional_spawns
11418 .lock()
11419 .unwrap_or_else(|poisoned| poisoned.into_inner())
11420 .remove(&instance_id);
11421 if removed.is_some() {
11422 tracing::info!(
11423 node_id = %self.node_id,
11424 instance_id = instance_id.0,
11425 "draft spawn claimed by HQ view",
11426 );
11427 }
11428 }
11429
11430 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11431 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11432 let mut map = self
11433 .provisional_spawns
11434 .lock()
11435 .unwrap_or_else(|poisoned| poisoned.into_inner());
11436 let due: Vec<AgentInstanceId> = map
11437 .iter()
11438 .filter(|(_, (_, at))| at.elapsed() >= grace)
11439 .map(|(id, _)| *id)
11440 .collect();
11441 due.into_iter()
11442 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11443 .collect()
11444 }
11445
11446 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11447 let instance_id = address.session.instance_id;
11448 tracing::warn!(
11449 node_id = %self.node_id,
11450 instance_id = instance_id.0,
11451 workspace = %address.workspace_id,
11452 reason = "attach_never_arrived",
11453 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11454 "draft spawn never viewed; stopping PTY",
11455 );
11456 let _ = self.dispatch(ControlCommand::Stop {
11457 instance_id,
11458 force: true,
11459 });
11460 let _ = self.remove_session(&address).await;
11461 }
11462
11463 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11464 if role != ClientRole::Operator {
11465 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11466 }
11467 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11468 let now = Instant::now();
11469 match controller.as_ref().copied() {
11470 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11471 Some(active) if active.expires_at <= now => {
11472 *controller = None;
11473 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11474 }
11475 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11476 }
11477 }
11478
11479 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11480 if !self
11481 .workspaces
11482 .read()
11483 .unwrap_or_else(|poisoned| poisoned.into_inner())
11484 .contains_key(&address.workspace_id)
11485 {
11486 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11487 }
11488 let binding = self
11489 .session_bindings
11490 .lock()
11491 .unwrap_or_else(|poisoned| poisoned.into_inner())
11492 .get(&address.session.instance_id)
11493 .cloned()
11494 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11495 if binding.workspace_id != address.workspace_id {
11496 return Err(failure(
11497 NodeFailureCode::SessionWorkspaceMismatch,
11498 "session belongs to another workspace",
11499 ));
11500 }
11501 if binding.generation != address.session.generation {
11502 return Err(failure(
11503 NodeFailureCode::StaleGeneration,
11504 &format!(
11505 "stale session generation: expected {}, received {}",
11506 binding.generation.0,
11507 address.session.generation.0,
11508 ),
11509 ));
11510 }
11511 let snapshot = self.handle.snapshot();
11512 let Some(current) = snapshot
11513 .sessions
11514 .iter()
11515 .find(|item| item.instance_id == address.session.instance_id)
11516 else {
11517 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11518 };
11519 if current.generation != address.session.generation {
11520 return Err(failure(
11521 NodeFailureCode::StaleGeneration,
11522 &format!(
11523 "stale session generation: expected {}, received {}",
11524 current.generation.0,
11525 address.session.generation.0,
11526 ),
11527 ));
11528 }
11529 Ok(current.agent_id.clone())
11530 }
11531
11532 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11533 self.dispatch_envelope(self.prepare_command(command))
11534 }
11535
11536 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11537 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11538 CommandEnvelope {
11539 id: CommandId(id),
11540 command,
11541 }
11542 }
11543
11544 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11545 let command_id = envelope.id;
11546 self.handle
11547 .dispatch(envelope)
11548 .map_err(|error| match error {
11549 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11550 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11551 })?;
11552 Ok(command_id)
11553 }
11554
11555 fn record_rejected_command(
11564 &self,
11565 command_id: CommandId,
11566 message: String,
11567 unsupported_transport: bool,
11568 ) {
11569 let now = Instant::now();
11570 let mut reasons = self.rejected_command_reasons.lock()
11571 .unwrap_or_else(|poisoned| poisoned.into_inner());
11572 reasons.retain(|_, reason| reason.expires_at > now);
11573 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11574 if let Some(&oldest) = reasons.keys().next() {
11575 reasons.remove(&oldest);
11576 }
11577 }
11578 reasons.insert(
11579 command_id,
11580 RejectedCommandReason {
11581 message,
11582 unsupported_transport,
11583 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11584 },
11585 );
11586 }
11587
11588 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11595 let now = Instant::now();
11596 let mut reasons = self.rejected_command_reasons.lock()
11597 .unwrap_or_else(|poisoned| poisoned.into_inner());
11598 match reasons.remove(&command_id) {
11599 Some(reason) if reason.expires_at > now => Some(reason),
11600 _ => None,
11601 }
11602 }
11603
11604 async fn dispatch_bounded(
11605 &self,
11606 command: ControlCommand,
11607 timeout_duration: Duration,
11608 ) -> Result<CommandId, NodeFailure> {
11609 let deadline = Instant::now() + timeout_duration;
11610 loop {
11611 match self.dispatch(command.clone()) {
11612 Ok(command_id) => return Ok(command_id),
11613 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11614 sleep(Duration::from_millis(2)).await;
11615 }
11616 Err(error) => return Err(error),
11617 }
11618 }
11619 }
11620
11621 fn internal_session_snapshot(
11622 &self,
11623 address: &SessionAddress,
11624 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11625 self.validate_address(address)?;
11626 self.handle
11627 .snapshot()
11628 .sessions
11629 .clone()
11630 .into_iter()
11631 .find(|session| {
11632 session.instance_id == address.session.instance_id
11633 && session.generation == address.session.generation
11634 })
11635 .ok_or_else(|| {
11636 failure(
11637 NodeFailureCode::UnknownSession,
11638 "session snapshot is unavailable",
11639 )
11640 })
11641 }
11642
11643 fn require_acp_session(
11651 &self,
11652 address: &SessionAddress,
11653 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11654 let snapshot = self.internal_session_snapshot(address)?;
11655 if snapshot.transport != TransportKind::Acp {
11656 return Err(failure(
11657 NodeFailureCode::UnsupportedTransport,
11658 "this verb requires a session spawned over the ACP transport",
11659 ));
11660 }
11661 Ok(snapshot)
11662 }
11663
11664 async fn dispatch_history_bounded(
11665 &self,
11666 address: &SessionAddress,
11667 command: ControlCommand,
11668 operation: HistoryOperation,
11669 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11670 self.validate_address(address)?;
11671 let started_after = self.current_sequence();
11672 let command_id = self
11673 .dispatch_bounded(
11674 command,
11675 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11676 )
11677 .await?;
11678 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11679 let mut scan_after = started_after;
11680 let mut accepted_after = None;
11681 loop {
11682 let events = self
11683 .history
11684 .lock()
11685 .unwrap_or_else(|poisoned| poisoned.into_inner())
11686 .events
11687 .iter()
11688 .filter(|envelope| envelope.sequence > scan_after)
11689 .cloned()
11690 .collect::<Vec<_>>();
11691 for envelope in events {
11692 scan_after = scan_after.max(envelope.sequence);
11693 let NodeEvent::Control {
11694 address: event_address,
11695 event,
11696 } = envelope.event
11697 else {
11698 continue;
11699 };
11700 if event_address != *address {
11701 continue;
11702 }
11703 if event.command_id == Some(command_id) {
11704 match &event.event {
11705 ControlEventKind::CommandRejected { .. } => {
11706 return Err(failure(
11707 NodeFailureCode::InvalidRequest,
11708 "history request was rejected",
11709 ));
11710 }
11711 ControlEventKind::HistoryRequested {
11712 operation: requested,
11713 ..
11714 } if requested == &operation => {
11715 accepted_after = Some(envelope.sequence);
11716 continue;
11717 }
11718 _ => {}
11719 }
11720 }
11721 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11722 match (&operation, &event.event) {
11723 (
11724 HistoryOperation::Discover { .. },
11725 ControlEventKind::HistoryDiscovered { .. },
11726 )
11727 | (
11728 HistoryOperation::Load { .. },
11729 ControlEventKind::HistoryLoaded { .. },
11730 ) => return self.internal_session_snapshot(address),
11731 (_, ControlEventKind::HistoryFailed { .. }) => {
11732 return Err(failure(
11733 NodeFailureCode::BackendOperationFailed,
11734 "history operation failed",
11735 ));
11736 }
11737 _ => {}
11738 }
11739 }
11740 }
11741 if Instant::now() >= deadline {
11742 return Err(failure(
11743 NodeFailureCode::BackendBusy,
11744 if accepted_after.is_some() {
11745 "history operation did not settle before the bounded deadline"
11746 } else {
11747 "history operation was not accepted before the bounded deadline"
11748 },
11749 ));
11750 }
11751 sleep(Duration::from_millis(2)).await;
11752 }
11753 }
11754
11755 async fn discover_history(
11756 &self,
11757 address: &SessionAddress,
11758 limit: u16,
11759 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11760 let query = HistoryQuery {
11761 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11762 limit,
11763 };
11764 query
11765 .validate()
11766 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11767 let snapshot = self
11768 .dispatch_history_bounded(
11769 address,
11770 ControlCommand::DiscoverHistory {
11771 instance_id: address.session.instance_id,
11772 query: query.clone(),
11773 },
11774 HistoryOperation::Discover { query },
11775 )
11776 .await?;
11777 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11778 return Err(failure(
11779 NodeFailureCode::BackendOperationFailed,
11780 "history discovery settled into an invalid state",
11781 ));
11782 }
11783 Ok(snapshot.history.candidates)
11784 }
11785
11786 async fn load_history(
11787 &self,
11788 address: &SessionAddress,
11789 candidate_id: String,
11790 ) -> Result<HistorySessionRecord, NodeFailure> {
11791 validate_candidate_id(&candidate_id)
11792 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11793 let snapshot = self
11794 .dispatch_history_bounded(
11795 address,
11796 ControlCommand::LoadHistory {
11797 instance_id: address.session.instance_id,
11798 candidate_id: candidate_id.clone(),
11799 },
11800 HistoryOperation::Load {
11801 candidate_id: candidate_id.clone(),
11802 },
11803 )
11804 .await?;
11805 if snapshot.history.pending.is_some()
11806 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11807 {
11808 return Err(failure(
11809 NodeFailureCode::BackendOperationFailed,
11810 "history load settled into an invalid state",
11811 ));
11812 }
11813 snapshot.history.loaded.ok_or_else(|| {
11814 failure(
11815 NodeFailureCode::BackendOperationFailed,
11816 "history load produced no bounded session",
11817 )
11818 })
11819 }
11820
11821 async fn export_context_pack_for_session_record(
11828 &self,
11829 record_id: &SessionRecordId,
11830 session: &SessionAddress,
11831 allow_clean_detachment: bool,
11832 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11833 let outcome = self
11839 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11840 .await;
11841 if let Err(failure) = &outcome {
11842 tracing::warn!(
11843 record = %record_id,
11844 code = ?failure.code,
11845 reason = %failure.message,
11846 "session-record context-pack export refused",
11847 );
11848 }
11849 outcome
11850 }
11851
11852 async fn export_context_pack_for_session_record_inner(
11853 &self,
11854 record_id: &SessionRecordId,
11855 session: &SessionAddress,
11856 allow_clean_detachment: bool,
11857 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11858 let step = |name: &'static str| move |failure: NodeFailure| {
11863 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11864 failure
11865 };
11866 let record = self.record(record_id).map_err(step("record-lookup"))?;
11867 let identity = match record.provider_session.clone() {
11883 Some(identity) => {
11884 if identity.key != ProviderSessionKey::SessionId {
11885 return Err(step("provider-identity-key")(failure(
11886 NodeFailureCode::BackendOperationFailed,
11887 "managed session provider identity cannot bind history exactly",
11888 )));
11889 }
11890 Some(identity)
11891 }
11892 None if record.state == ManagedSessionState::IdentityPending => {
11893 return Err(step("provider-session-identity")(failure(
11894 NodeFailureCode::BackendOperationFailed,
11895 "managed session has not yet observed its provider session identity",
11896 )));
11897 }
11898 None => None,
11899 };
11900 self.revalidate_session_record_context_export(
11901 &record,
11902 identity.as_ref(),
11903 session,
11904 allow_clean_detachment,
11905 ).map_err(step("revalidate-before-discovery"))?;
11906 let candidates = self
11907 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11908 .await
11909 .map_err(step("discover-history"))?;
11910 let candidate_id = select_history_candidate(
11915 &candidates,
11916 identity.as_ref().map(|identity| identity.id.as_str()),
11917 ).map_err(step("unique-candidate"))?;
11918 let loaded = self.load_history(session, candidate_id.clone()).await
11919 .map_err(step("load-history"))?;
11920 if let Some(identity) = identity.as_ref() {
11921 if loaded.session_id != identity.id {
11922 return Err(failure(
11923 NodeFailureCode::SessionRecordConflict,
11924 "loaded history identity does not match the managed session record",
11925 ));
11926 }
11927 }
11928 self.revalidate_session_record_context_export(
11929 &record,
11930 identity.as_ref(),
11931 session,
11932 allow_clean_detachment,
11933 )?;
11934 self.revalidate_loaded_history(session, &candidate_id, &loaded)
11935 .map_err(step("revalidate-loaded-history"))?;
11936 let pack = self.materialize_context_pack(session).await
11937 .map_err(step("materialize-context-pack"))?;
11938 self.commit_context_pack_for_session_record(
11939 &record,
11940 identity.as_ref(),
11941 session,
11942 &candidate_id,
11943 &loaded,
11944 pack,
11945 allow_clean_detachment,
11946 )
11947 }
11948
11949 async fn export_context_pack(
11950 &self,
11951 address: &SessionAddress,
11952 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11953 let pack = self.materialize_context_pack(address).await?;
11954 self.commit_context_pack(pack)
11955 }
11956
11957 async fn materialize_context_pack(
11958 &self,
11959 address: &SessionAddress,
11960 ) -> Result<NodeContextPack, NodeFailure> {
11961 let snapshot = self.internal_session_snapshot(address)?;
11962 if !context_pack_source_status_is_usable(&snapshot.status) {
11963 return Err(failure(
11964 NodeFailureCode::InvalidRequest,
11965 "context export requires a running or successfully exited source session",
11966 ));
11967 }
11968 if snapshot.history.pending.is_some()
11969 || snapshot.history.loaded_candidate_id.is_none()
11970 {
11971 return Err(failure(
11972 NodeFailureCode::InvalidRequest,
11973 "context export requires settled loaded history",
11974 ));
11975 }
11976 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
11977 failure(
11978 NodeFailureCode::InvalidRequest,
11979 "context export requires settled loaded history",
11980 )
11981 })?;
11982 let source_provider = snapshot.agent_id.clone();
11983 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
11984 let repository = self
11985 .context_pack_repository(&address.workspace_id)
11986 .await?;
11987 let current = self.internal_session_snapshot(address)?;
11988 if !context_pack_source_status_is_usable(¤t.status)
11989 || current.agent_id != source_provider
11990 || current.history.pending.is_some()
11991 || current.history.loaded_candidate_id != loaded_candidate_id
11992 || current.history.loaded.as_ref() != Some(&loaded)
11993 {
11994 return Err(failure(
11995 NodeFailureCode::ContextPackMaterializationFailed,
11996 "context source changed during bounded repository capture",
11997 ));
11998 }
11999 NodeContextPack::export_with_repository(
12000 ContextPackLineageReceipt {
12001 source_node_id: self.node_id.clone(),
12002 source_session: address.clone(),
12003 source_provider,
12004 },
12005 &loaded,
12006 Some(repository),
12007 )
12008 .map_err(|_| {
12009 failure(
12010 NodeFailureCode::ContextPackMaterializationFailed,
12011 "bounded context pack export failed",
12012 )
12013 })
12014 }
12015
12016 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12022 let mut store = self
12023 .context_pack_store
12024 .lock()
12025 .unwrap_or_else(|poisoned| poisoned.into_inner());
12026 let Some(store) = store.as_mut() else {
12027 return Ok(());
12028 };
12029 store.commit(pack).map_err(|_| {
12030 failure(
12031 NodeFailureCode::ContextPackMaterializationFailed,
12032 "durable context pack commit failed",
12033 )
12034 })
12035 }
12036
12037 fn commit_context_pack(
12038 &self,
12039 pack: NodeContextPack,
12040 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12041 self.commit_context_pack_durably(&pack)?;
12042 self.context_catalog
12043 .write()
12044 .unwrap_or_else(|poisoned| poisoned.into_inner())
12045 .insert(pack)
12046 .map_err(|_| {
12047 failure(
12048 NodeFailureCode::ContextPackMaterializationFailed,
12049 "context pack catalog is full",
12050 )
12051 })
12052 }
12053
12054 fn commit_context_pack_for_session_record(
12055 &self,
12056 expected: &ManagedSessionRecord,
12057 identity: Option<&ProviderSessionIdentity>,
12058 session: &SessionAddress,
12059 candidate_id: &str,
12060 loaded: &HistorySessionRecord,
12061 pack: NodeContextPack,
12062 allow_clean_detachment: bool,
12063 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12064 let current_root = self.workspace_root(&expected.workspace_id)?;
12065 let _transaction = self
12066 .state_transaction
12067 .lock()
12068 .unwrap_or_else(|poisoned| poisoned.into_inner());
12069 let records = self
12070 .session_records
12071 .lock()
12072 .unwrap_or_else(|poisoned| poisoned.into_inner());
12073 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12074 failure(
12075 NodeFailureCode::SessionRecordConflict,
12076 "managed session record disappeared during context export",
12077 )
12078 })?;
12079 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12080 return Err(failure(
12081 NodeFailureCode::SessionRecordConflict,
12082 "managed session record changed during context export",
12083 ));
12084 }
12085 let bindings = self
12086 .session_bindings
12087 .lock()
12088 .unwrap_or_else(|poisoned| poisoned.into_inner());
12089 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12090 failure(
12091 NodeFailureCode::SessionRecordConflict,
12092 "managed session runtime binding disappeared during context export",
12093 )
12094 })?;
12095 let runtime = self
12096 .handle
12097 .snapshot()
12098 .sessions
12099 .iter()
12100 .find(|snapshot| {
12101 snapshot.instance_id == session.session.instance_id
12102 && snapshot.generation == session.session.generation
12103 })
12104 .cloned()
12105 .ok_or_else(|| {
12106 failure(
12107 NodeFailureCode::SessionRecordConflict,
12108 "managed session runtime disappeared during context export",
12109 )
12110 })?;
12111 let receipt = pack.receipt();
12112 if !session_record_context_export_binding_is_exact(
12113 expected,
12114 current,
12115 identity,
12116 session,
12117 binding,
12118 &runtime.agent_id,
12119 ¤t_root,
12120 allow_clean_detachment,
12121 ) || !session_record_context_export_source_is_usable(
12122 current,
12123 &runtime.status,
12124 allow_clean_detachment,
12125 ) || runtime.history.pending.is_some()
12126 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12127 || runtime.history.loaded.as_ref() != Some(loaded)
12128 || receipt.lineage.source_node_id != self.node_id
12129 || receipt.lineage.source_session != *session
12130 || receipt.lineage.source_provider != current.provider
12131 || receipt.source_message_count != loaded.message_count
12132 {
12133 return Err(failure(
12134 NodeFailureCode::SessionRecordConflict,
12135 "managed session source changed before context catalog commit",
12136 ));
12137 }
12138 self.commit_context_pack_durably(&pack)?;
12139 self.context_catalog
12140 .write()
12141 .unwrap_or_else(|poisoned| poisoned.into_inner())
12142 .insert(pack)
12143 .map_err(|_| {
12144 failure(
12145 NodeFailureCode::ContextPackMaterializationFailed,
12146 "context pack catalog is full",
12147 )
12148 })
12149 }
12150
12151 async fn context_pack_repository(
12152 &self,
12153 workspace_id: &WorkspaceId,
12154 ) -> Result<ContextPackRepository, NodeFailure> {
12155 let first = self
12156 .context_pack_repository_observation(workspace_id)
12157 .await?;
12158 let second = self
12159 .context_pack_repository_observation(workspace_id)
12160 .await?;
12161 stable_context_pack_repository(first, second)
12162 }
12163
12164 async fn context_pack_repository_observation(
12165 &self,
12166 workspace_id: &WorkspaceId,
12167 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12168 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12169 let head = if git.is_repository {
12170 let root = self.workspace_root(workspace_id)?;
12171 match resolve_base_commit_with_timeout(
12172 &root,
12173 "HEAD",
12174 GIT_COMMAND_TIMEOUT_MS,
12175 )
12176 .await
12177 {
12178 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12179 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12180 ContextPackRepositoryHead::Unborn
12181 }
12182 Err(_) => {
12183 return Err(failure(
12184 NodeFailureCode::ContextPackMaterializationFailed,
12185 "context pack HEAD observation failed",
12186 ));
12187 }
12188 }
12189 } else {
12190 ContextPackRepositoryHead::NotRepository
12191 };
12192 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12193 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12194 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12195 failure(
12196 NodeFailureCode::InvalidRepositoryPath,
12197 "context pack selected file path is invalid",
12198 )
12199 })?;
12200 match self.read_workspace_file(workspace_id.clone(), path).await {
12201 Ok(WorkspaceFileRead {
12202 content: WorkspaceFileContent::Utf8 { text, byte_len },
12203 ..
12204 }) => files.push(ContextPackRepositoryFileSource::utf8(
12205 selected_path,
12206 text,
12207 byte_len,
12208 )),
12209 Ok(WorkspaceFileRead {
12210 content: WorkspaceFileContent::NonUtf8 { byte_len },
12211 ..
12212 }) => files.push(ContextPackRepositoryFileSource::skipped(
12213 selected_path,
12214 ContextPackSelectedFileSkipReason::NonUtf8,
12215 Some(byte_len),
12216 )),
12217 Ok(WorkspaceFileRead {
12218 content: WorkspaceFileContent::TooLarge { .. },
12219 ..
12220 }) => files.push(ContextPackRepositoryFileSource::skipped(
12221 selected_path,
12222 ContextPackSelectedFileSkipReason::TooLarge,
12223 None,
12224 )),
12225 Err(error) => {
12226 let reason = if matches!(
12227 error.code,
12228 NodeFailureCode::InvalidRepositoryPath
12229 | NodeFailureCode::RepositoryFileNotRegular
12230 | NodeFailureCode::RepositoryPathUnsafe
12231 ) {
12232 ContextPackSelectedFileSkipReason::Unsafe
12233 } else {
12234 ContextPackSelectedFileSkipReason::Unavailable
12235 };
12236 files.push(ContextPackRepositoryFileSource::skipped(
12237 selected_path,
12238 reason,
12239 None,
12240 ));
12241 }
12242 }
12243 }
12244 Ok(ContextPackRepositoryObservation { head, git, files })
12245 }
12246
12247 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12248 let referenced_by_binding = self
12249 .session_bindings
12250 .lock()
12251 .unwrap_or_else(|poisoned| poisoned.into_inner())
12252 .values()
12253 .any(|binding| {
12254 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12255 });
12256 let referenced_by_record = self
12257 .session_records
12258 .lock()
12259 .unwrap_or_else(|poisoned| poisoned.into_inner())
12260 .records
12261 .values()
12262 .any(|record| record.context_id.as_ref() == Some(context_id));
12263 let referenced_by_materialization = self
12264 .materializations
12265 .lock()
12266 .unwrap_or_else(|poisoned| poisoned.into_inner())
12267 .values()
12268 .any(|ownership| {
12269 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12270 });
12271 if referenced_by_binding
12272 || referenced_by_record
12273 || referenced_by_materialization
12274 {
12275 return Err(failure(
12276 NodeFailureCode::ContextPackBusy,
12277 "context pack is still referenced",
12278 ));
12279 }
12280 let mut catalog = self.context_catalog
12281 .write()
12282 .unwrap_or_else(|poisoned| poisoned.into_inner());
12283 if catalog.get(context_id).is_none() {
12284 return Err(failure(
12285 NodeFailureCode::UnknownContextPack,
12286 "context pack is unavailable",
12287 ));
12288 }
12289 self.spawn_idempotency
12290 .lock()
12291 .unwrap_or_else(|poisoned| poisoned.into_inner())
12292 .entries
12293 .retain(|_, entry| !entry.value.references_context(context_id));
12294 let removed = catalog.remove(context_id);
12295 debug_assert!(removed.is_some(), "validated context pack remains present");
12296 Ok(())
12297 }
12298
12299 async fn dispatch_input_bounded(
12300 &self,
12301 address: &SessionAddress,
12302 action: InputAction,
12303 ) -> Result<(), NodeFailure> {
12304 let started_after = self.current_sequence();
12305 self.pty_input_marks
12306 .lock()
12307 .unwrap_or_else(|poisoned| poisoned.into_inner())
12308 .insert(address.session.instance_id, (Instant::now(), false));
12309 self.pty_input_wake.notify_one();
12310 let command_id = self
12311 .dispatch_bounded(
12312 ControlCommand::SendInput {
12313 instance_id: address.session.instance_id,
12314 action,
12315 },
12316 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12317 )
12318 .await?;
12319 let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12323 let mut scan_after = started_after;
12324 let mut accepted_after = None;
12325 loop {
12326 let events = self
12327 .history
12328 .lock()
12329 .unwrap_or_else(|poisoned| poisoned.into_inner())
12330 .events
12331 .iter()
12332 .filter(|envelope| envelope.sequence > scan_after)
12333 .cloned()
12334 .collect::<Vec<_>>();
12335 for envelope in events {
12336 scan_after = scan_after.max(envelope.sequence);
12337 let NodeEvent::Control {
12338 address: event_address,
12339 event,
12340 } = envelope.event
12341 else {
12342 continue;
12343 };
12344 if event_address != *address {
12345 continue;
12346 }
12347 if event.command_id == Some(command_id) {
12348 match &event.event {
12349 ControlEventKind::CommandRejected { message } => {
12350 return Err(failure(NodeFailureCode::InvalidRequest, message));
12351 }
12352 ControlEventKind::InputRequested { .. } => {
12353 accepted_after = Some(envelope.sequence);
12354 continue;
12355 }
12356 _ => {}
12357 }
12358 }
12359 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12360 match &event.event {
12361 ControlEventKind::InputCompleted { .. } => return Ok(()),
12362 ControlEventKind::InputFailed { message, .. } => {
12363 return Err(failure(
12364 NodeFailureCode::BackendOperationFailed,
12365 message,
12366 ));
12367 }
12368 _ => {}
12369 }
12370 }
12371 }
12372 if Instant::now() >= deadline {
12373 return Err(failure(
12374 NodeFailureCode::BackendBusy,
12375 if accepted_after.is_some() {
12376 "input was accepted but did not settle before the bounded deadline"
12377 } else {
12378 "input was not accepted before the bounded deadline"
12379 },
12380 ));
12381 }
12382 sleep(Duration::from_millis(2)).await;
12383 }
12384 }
12385
12386 async fn dispatch_resize_bounded(
12387 &self,
12388 address: &SessionAddress,
12389 size: gate4agent_types::TerminalSize,
12390 ) -> Result<(), NodeFailure> {
12391 let started_after = self.current_sequence();
12392 let command_id = self
12393 .dispatch_bounded(
12394 ControlCommand::Resize {
12395 instance_id: address.session.instance_id,
12396 size,
12397 },
12398 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12399 )
12400 .await?;
12401 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12402 let mut scan_after = started_after;
12403 let mut accepted_after = None;
12404 loop {
12405 let events = self
12406 .history
12407 .lock()
12408 .unwrap_or_else(|poisoned| poisoned.into_inner())
12409 .events
12410 .iter()
12411 .filter(|envelope| envelope.sequence > scan_after)
12412 .cloned()
12413 .collect::<Vec<_>>();
12414 for envelope in events {
12415 scan_after = scan_after.max(envelope.sequence);
12416 let NodeEvent::Control {
12417 address: event_address,
12418 event,
12419 } = envelope.event
12420 else {
12421 continue;
12422 };
12423 if event_address != *address {
12424 continue;
12425 }
12426 if event.command_id == Some(command_id) {
12427 match &event.event {
12428 ControlEventKind::CommandRejected { message } => {
12429 return Err(failure(NodeFailureCode::InvalidRequest, message));
12430 }
12431 ControlEventKind::ResizeRequested {
12432 size: requested_size,
12433 ..
12434 } if *requested_size == size => {
12435 accepted_after = Some(envelope.sequence);
12436 continue;
12437 }
12438 _ => {}
12439 }
12440 }
12441 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12442 match &event.event {
12443 ControlEventKind::Resized { size: settled_size }
12444 if *settled_size == size =>
12445 {
12446 return Ok(());
12447 }
12448 ControlEventKind::ResizeFailed { message } => {
12449 return Err(failure(
12450 NodeFailureCode::BackendOperationFailed,
12451 message,
12452 ));
12453 }
12454 _ => {}
12455 }
12456 }
12457 }
12458 if Instant::now() >= deadline {
12459 return Err(failure(
12460 NodeFailureCode::BackendBusy,
12461 if accepted_after.is_some() {
12462 "resize was accepted but did not settle before the bounded deadline"
12463 } else {
12464 "resize was not accepted before the bounded deadline"
12465 },
12466 ));
12467 }
12468 sleep(Duration::from_millis(2)).await;
12469 }
12470 }
12471
12472 async fn spawn_session(
12473 &self,
12474 workspace_id: WorkspaceId,
12475 provider: AgentId,
12476 mode: SessionMode,
12477 terminal_size: gate4agent_types::TerminalSize,
12478 initial_prompt: Option<String>,
12479 ) -> Result<SessionAddress, NodeFailure> {
12480 if self.managed_worktrees.lock()
12481 .unwrap_or_else(|poisoned| poisoned.into_inner())
12482 .lease_for_workspace(&workspace_id)
12483 .is_some()
12484 {
12485 return Err(failure(
12486 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12487 "managed worktree targets may only be spawned through their lease request",
12488 ));
12489 }
12490 self
12491 .spawn_session_with_deadline(
12492 workspace_id,
12493 provider,
12494 mode,
12495 terminal_size,
12496 initial_prompt,
12497 None,
12498 None,
12499 None,
12500 None,
12501 None,
12502 SpawnRecordPolicy::ProviderIdentityOnly,
12503 None,
12504 &[],
12505 None,
12506 ApprovalLevel::default(),
12512 None,
12513 None,
12514 )
12515 .await
12516 .map(|(session, _runtime_policy)| session)
12517 }
12518
12519 async fn spawn_session_with_deadline(
12520 &self,
12521 workspace_id: WorkspaceId,
12522 provider: AgentId,
12523 mode: SessionMode,
12524 terminal_size: gate4agent_types::TerminalSize,
12525 initial_prompt: Option<String>,
12526 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12527 bundle: Option<ResolvedBundleReceipt>,
12528 context: Option<ResolvedContextPackReceipt>,
12529 managed_authority: Option<ManagedWorktreeLeaseId>,
12530 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12531 record_policy: SpawnRecordPolicy,
12532 deadline: Option<Instant>,
12533 required_capabilities: &[ProviderRuntimeCapability],
12534 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12535 approval_level: ApprovalLevel,
12536 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12537 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12543 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12544 let reserved_lease = self.managed_worktrees.lock()
12545 .unwrap_or_else(|poisoned| poisoned.into_inner())
12546 .lease_for_workspace(&workspace_id);
12547 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12548 (Some(expected), Some(provided)) if expected == provided => {}
12549 (None, None) => {}
12550 _ => {
12551 return Err(failure(
12552 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12553 "spawn lacks exact managed worktree lease authority",
12554 ));
12555 }
12556 }
12557 self.ensure_workspace_environment_profile_compatible(
12561 &workspace_id,
12562 environment_profile.as_ref(),
12563 )?;
12564 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12565 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12566 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12567 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12568 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12569 };
12570 let transport = match mode {
12576 SessionMode::Pty => TransportKind::Pty,
12577 SessionMode::Inline => TransportKind::Pipe,
12578 SessionMode::Acp => TransportKind::Acp,
12579 };
12580 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12581 let acp_transport = self.provider_supports_acp_transport(&provider);
12582 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12583 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12584 NodeFailureCode::UnsupportedSpawnCapability,
12585 "pre-admitted provider runtime no longer satisfies spawn requirements",
12586 ))?;
12587 runtime_policy
12588 } else if let Some(deadline) = deadline {
12589 let remaining = spawn_deadline_remaining(deadline)?;
12590 timeout(
12591 remaining,
12592 self.admit_provider_runtime(&provider, runtime_requirement),
12593 )
12594 .await
12595 .map_err(|_| {
12596 failure(
12597 NodeFailureCode::SpawnDeadlineExceeded,
12598 "provider admission exceeded the spawn deadline",
12599 )
12600 })??
12601 } else {
12602 self.admit_provider_runtime(&provider, runtime_requirement).await?
12603 };
12604 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12605 let runtime_policy =
12617 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12618 if required_capabilities
12619 .iter()
12620 .any(|capability| !runtime_policy.admits(*capability))
12621 {
12622 return Err(failure(
12623 NodeFailureCode::UnsupportedSpawnCapability,
12624 "provider runtime does not admit a required spawn capability",
12625 ));
12626 }
12627 if let Some(deadline) = deadline {
12628 spawn_deadline_remaining(deadline)?;
12629 }
12630 self.ensure_binding_capacity()?;
12631 let working_directory = self.workspace_root(&workspace_id)?;
12632 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12633 let session = SessionKey {
12634 instance_id,
12635 generation: SessionGeneration(1),
12636 };
12637 let address = SessionAddress {
12638 workspace_id: workspace_id.clone(),
12639 session,
12640 };
12641 tracing::info!(
12649 node_id = %self.node_id,
12650 workspace_id = %workspace_id,
12651 provider = %provider,
12652 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12653 session = ?address.session,
12654 "session spawn request accepted",
12655 );
12656 let mut prepared_spawn = SpawnPreparer::prepare(
12657 self,
12658 SpawnPrepareRequest {
12659 instance_id,
12660 address: &address,
12661 provider: &provider,
12662 mode,
12663 environment_profile: environment_profile.as_ref(),
12664 bundle: bundle.as_ref(),
12665 context: context.as_ref(),
12666 managed_authority: managed_authority.as_ref(),
12667 harness_mcp,
12668 approval_level,
12669 network_allowlist: network_allowlist.as_ref(),
12670 browser_profile_id,
12671 },
12672 )?;
12673 let materialization_id = prepared_spawn.materialization_id.take();
12674 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12675 let record_id = self.bind_spawn_session_with_materialization(
12676 &address,
12677 provider.clone(),
12678 mode,
12679 runtime_policy,
12680 record_policy,
12681 environment_profile,
12682 bundle,
12683 context,
12684 materialization_id,
12685 )?;
12686 prepared_spawn.retain_session_guards();
12687 let agent_id = provider;
12691 let register_command_id = match self.dispatch_bounded(
12692 ControlCommand::Register {
12693 instance_id,
12694 agent_id,
12695 transport,
12696 },
12697 dispatch_timeout,
12698 )
12699 .await {
12700 Ok(command_id) => command_id,
12701 Err(error) => {
12702 if let Some(binding) = self.remove_binding(&address) {
12703 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12704 }
12705 if let Some(record_id) = record_id.as_ref() {
12706 self.discard_record(record_id)?;
12707 }
12708 return Err(spawn_dispatch_error(error, deadline));
12709 }
12710 };
12711 let start_timeout = match spawn_dispatch_timeout(deadline) {
12712 Ok(timeout) => timeout,
12713 Err(error) => {
12714 let recovery = self
12715 .rollback_spawn(
12716 &address,
12717 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12718 )
12719 .await;
12720 return match recovery {
12721 Ok(()) => {
12722 if let Some(record_id) = record_id.as_ref() {
12723 self.discard_record(record_id)?;
12724 }
12725 Err(error)
12726 }
12727 Err(recovery_error) => {
12728 if let Some(record_id) = record_id.as_ref() {
12729 self.mark_record_error(
12730 record_id,
12731 "spawn deadline elapsed and registration recovery failed",
12732 )?;
12733 }
12734 Err(recovery_error)
12735 }
12736 };
12737 }
12738 };
12739 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12740 if let Err(recovery_error) = self
12741 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12742 .await
12743 {
12744 return Err(recovery_error);
12745 }
12746 if let Some(record_id) = record_id.as_ref() {
12747 self.discard_record(record_id)?;
12748 }
12749 return Err(start_error);
12750 }
12751 let start_result = self
12752 .dispatch_bounded(
12753 ControlCommand::Start {
12754 instance_id,
12755 runtime_policy,
12756 request: StartRequest {
12757 working_directory,
12758 terminal_size,
12759 initial_prompt,
12760 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12761 approval_level,
12762 },
12763 },
12764 start_timeout,
12765 )
12766 .await;
12767 let start_command_id = match start_result {
12768 Ok(command_id) => command_id,
12769 Err(start_error) => {
12770 let start_error = spawn_dispatch_error(start_error, deadline);
12771 let recovery = self
12772 .rollback_spawn(
12773 &address,
12774 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12775 )
12776 .await;
12777 return match recovery {
12778 Ok(()) => {
12779 if let Some(record_id) = record_id.as_ref() {
12780 self.discard_record(record_id)?;
12781 }
12782 Err(start_error)
12783 }
12784 Err(recovery_error) => {
12785 let diagnostic = format!(
12786 "start failed and registration recovery failed: {}",
12787 recovery_error.message,
12788 );
12789 if let Some(record_id) = record_id.as_ref() {
12790 self.mark_record_error(record_id, &diagnostic)?;
12791 }
12792 Err(failure(recovery_error.code, &diagnostic))
12793 }
12794 };
12795 }
12796 };
12797
12798 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12799 loop {
12800 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12809 .or_else(|| self.take_rejected_command_reason(start_command_id))
12810 {
12811 self.rollback_spawn(
12812 &address,
12813 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12814 ).await.map_err(|error| {
12815 failure(
12816 error.code,
12817 &format!(
12818 "spawn was rejected ({}) and registration recovery failed: {}",
12819 reason.message, error.message
12820 ),
12821 )
12822 })?;
12823 if let Some(record_id) = record_id.as_ref() {
12824 self.discard_record(record_id)?;
12825 }
12826 let code = if reason.unsupported_transport {
12827 NodeFailureCode::UnsupportedTransport
12828 } else {
12829 NodeFailureCode::BackendOperationFailed
12830 };
12831 return Err(failure(code, &reason.message));
12832 }
12833 if let Some(current) = self
12834 .handle
12835 .snapshot()
12836 .sessions
12837 .iter()
12838 .find(|current| current.instance_id == instance_id)
12839 {
12840 if current.generation != session.generation {
12841 let divergence = failure(
12842 NodeFailureCode::StaleGeneration,
12843 "spawn generation diverged",
12844 );
12845 return match self
12846 .rollback_spawn(
12847 &address,
12848 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12849 )
12850 .await
12851 {
12852 Ok(()) => {
12853 if let Some(record_id) = record_id.as_ref() {
12854 self.discard_record(record_id)?;
12855 }
12856 Err(divergence)
12857 }
12858 Err(recovery_error) => {
12859 if let Some(record_id) = record_id.as_ref() {
12860 self.mark_record_error(
12861 record_id,
12862 "spawn generation diverged and recovery failed",
12863 )?;
12864 }
12865 Err(recovery_error)
12866 }
12867 };
12868 }
12869 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12870 prepared_spawn.retain_launch_guards();
12871 return Ok((address, runtime_policy));
12872 }
12873 }
12874 if Instant::now() >= commit_deadline {
12875 self.rollback_spawn(
12876 &address,
12877 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12878 ).await.map_err(|error| {
12879 failure(
12880 error.code,
12881 &format!(
12882 "start did not commit and registration recovery failed: {}",
12883 error.message
12884 ),
12885 )
12886 })?;
12887 if let Some(record_id) = record_id.as_ref() {
12888 self.discard_record(record_id)?;
12889 }
12890 return Err(failure(
12891 if deadline.is_some() {
12892 NodeFailureCode::SpawnDeadlineExceeded
12893 } else {
12894 NodeFailureCode::BackendBusy
12895 },
12896 "start did not commit before the bounded deadline; registration was removed",
12897 ));
12898 }
12899 sleep(Duration::from_millis(2)).await;
12900 }
12901 }
12902
12903 fn effective_spawn_runtime_policy(
12904 &self,
12905 runtime_policy: ProviderRuntimePolicy,
12906 ) -> ProviderRuntimePolicy {
12907 #[cfg(feature = "fixture")]
12908 let runtime_policy = if self.fixture_semantic_hook_policy {
12909 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12910 .expect("monitoring Hook fixture policy is internally valid")
12911 } else {
12912 runtime_policy
12913 };
12914 runtime_policy
12915 }
12916
12917 fn verify_harness_mcp_before_start(
12918 &self,
12919 prepared: Option<&PreparedHarnessMcpSpawn>,
12920 ) -> Result<(), NodeFailure> {
12921 prepared
12922 .map(PreparedHarnessMcpSpawn::verify_helper_program)
12923 .transpose()
12924 .map(|_| ())
12925 .map_err(harness_mcp_failure)
12926 }
12927
12928 async fn rollback_spawn(
12929 &self,
12930 address: &SessionAddress,
12931 commit_timeout: Duration,
12932 ) -> Result<(), NodeFailure> {
12933 self.dispatch_bounded(
12934 ControlCommand::Remove {
12935 instance_id: address.session.instance_id,
12936 },
12937 commit_timeout,
12938 )
12939 .await?;
12940 self.wait_until_removed(address, commit_timeout).await?;
12941 if let Some(binding) = self.remove_binding(address) {
12942 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
12943 }
12944 Ok(())
12945 }
12946
12947 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
12948 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
12949 self.dispatch_bounded(
12950 ControlCommand::Remove {
12951 instance_id: address.session.instance_id,
12952 },
12953 commit_timeout,
12954 )
12955 .await?;
12956 self.wait_until_removed(address, commit_timeout).await?;
12957 let removed = self.remove_binding(address);
12958 if let Some(binding) = removed.as_ref() {
12959 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
12960 }
12961 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
12962 let snapshot = self.managed_worktrees.lock()
12963 .unwrap_or_else(|poisoned| poisoned.into_inner())
12964 .release_session(
12965 &lease_id,
12966 self.incarnation_id,
12967 address.session.instance_id,
12968 address.session.generation,
12969 unix_time_ms(),
12970 );
12971 if self.persist_state().is_err() {
12972 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
12973 }
12974 if let Some(snapshot) = snapshot {
12975 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
12976 }
12977 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
12978 }
12979 Ok(())
12980 }
12981
12982 async fn wait_until_removed(
13028 &self,
13029 address: &SessionAddress,
13030 commit_timeout: Duration,
13031 ) -> Result<(), NodeFailure> {
13032 let deadline = Instant::now() + commit_timeout;
13033 let mut next_dispatch = Instant::now();
13034 loop {
13035 let snapshot = self.handle.snapshot();
13036 let status = snapshot
13037 .sessions
13038 .iter()
13039 .find(|session| session.instance_id == address.session.instance_id)
13040 .map(|session| session.status.clone());
13041 let Some(status) = status else {
13042 return Ok(());
13043 };
13044 let now = Instant::now();
13045 if now >= deadline {
13046 return Err(failure(
13047 NodeFailureCode::BackendBusy,
13048 "remove did not commit before the bounded deadline; session binding was retained",
13049 ));
13050 }
13051 if status.allows_remove() && now >= next_dispatch {
13052 let _ = self.dispatch(ControlCommand::Remove {
13053 instance_id: address.session.instance_id,
13054 });
13055 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13056 }
13057 sleep(Duration::from_millis(2)).await;
13058 }
13059 }
13060
13061 fn resync(&self, after_sequence: u64) -> NodeResponse {
13062 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13063 let event_sequence = history.last_sequence;
13064 let oldest_available_sequence = history.replay_floor_sequence;
13065 let events = history
13066 .events
13067 .iter()
13068 .filter(|event| event.sequence > after_sequence)
13069 .cloned()
13070 .collect();
13071 drop(history);
13072 NodeResponse::Resync {
13073 event_sequence,
13074 oldest_available_sequence,
13075 snapshot: self.snapshot(),
13076 events,
13077 }
13078 }
13079}
13080
13081fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13086 match kind {
13087 AgentStreamChunkKindV1::Text { .. } => "text",
13088 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13089 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13090 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13091 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13092 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13093 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13094 }
13095}
13096
13097async fn accept_connections(
13111 endpoint: &str,
13112 call_home: Option<std::net::SocketAddr>,
13113 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13114 shared: Arc<NodeShared>,
13115) -> Result<(), NodeServerError> {
13116 let mut connections = JoinSet::new();
13117 let result = match call_home {
13118 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13119 Some(relay) => {
13120 let listener =
13121 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13122 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13123 tokio::pin!(listener);
13124 tokio::pin!(dialler);
13125 tokio::select! {
13126 result = &mut listener => result,
13127 result = &mut dialler => result,
13128 }
13129 }
13130 };
13131 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13132 while !connections.is_empty() {
13133 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13134 if remaining.is_zero()
13135 || timeout(remaining, connections.join_next()).await.is_err()
13136 {
13137 break;
13138 }
13139 }
13140 connections.abort_all();
13141 while connections.join_next().await.is_some() {}
13142 result
13143}
13144
13145const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13149
13150async fn call_home_loop(
13164 relay: std::net::SocketAddr,
13165 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13166 shared: Arc<NodeShared>,
13167) -> Result<(), NodeServerError> {
13168 let mut failures = 0_usize;
13169 loop {
13170 if shared.shutdown.load(Ordering::Acquire) {
13171 return Ok(());
13172 }
13173 let permit = tokio::select! {
13174 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13175 permit.map_err(|_| NodeServerError::PreauthClosed)?
13176 }
13177 _ = shared.shutdown_notify.notified() => return Ok(()),
13178 };
13179 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13180 Ok(()) => {
13181 failures = 0;
13185 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13186 }
13187 Err(error) => {
13188 failures = failures.saturating_add(1);
13189 tracing::warn!(
13190 node_id = %shared.node_id,
13191 %relay,
13192 cause = %error,
13193 attempt = failures,
13194 "call-home connection failed",
13195 );
13196 let backoff = call_home_backoff(failures);
13197 tokio::select! {
13198 _ = sleep(backoff) => {}
13199 _ = shared.shutdown_notify.notified() => return Ok(()),
13200 }
13201 }
13202 }
13203 }
13204}
13205
13206const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13208const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13210
13211fn call_home_keepalive() -> socket2::TcpKeepalive {
13212 let keepalive = socket2::TcpKeepalive::new()
13213 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13214 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13215 #[cfg(not(windows))]
13216 let keepalive = keepalive.with_retries(3);
13217 keepalive
13218}
13219
13220fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13221 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13222}
13223
13224pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13226 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13227}
13228
13229fn call_home_backoff(attempt: usize) -> Duration {
13231 Duration::from_millis(
13232 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13233 )
13234}
13235
13236async fn dial_relay_and_serve(
13238 relay: std::net::SocketAddr,
13239 identity: Option<&hatchery_enroll::NodeIdentity>,
13240 shared: Arc<NodeShared>,
13241 preauth_permit: OwnedSemaphorePermit,
13242) -> Result<(), NodeServerError> {
13243 let mut stream = tokio::net::TcpStream::connect(relay)
13244 .await
13245 .map_err(NodeServerError::CallHomeTransport)?;
13246 let _ = stream.set_nodelay(true);
13250 if let Err(error) = enable_call_home_keepalive(&stream) {
13256 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13257 }
13258 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13259 .await
13260 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13261 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13262 let mut session_token = None;
13263 if let Some(identity) = identity {
13264 let mut magic = [0_u8; 4];
13268 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13269 .await
13270 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13271 .map_err(NodeServerError::CallHomeTransport)?;
13272 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13273 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13274 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13275 .await
13276 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13277 .map_err(NodeServerError::CallHomeTransport)?;
13278 let (answer, token) = identity
13279 .answer_challenge(shared.node_id.as_str(), &frame)
13280 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13281 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13282 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13283 session_token = Some(token);
13284 }
13285 }
13286 serve_connection(stream, shared, session_token, preauth_permit).await
13287}
13288
13289async fn accept_connections_inner(
13290 endpoint: &str,
13291 shared: Arc<NodeShared>,
13292 connections: &mut JoinSet<Result<(), NodeServerError>>,
13293) -> Result<(), NodeServerError> {
13294 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13295 loop {
13296 while connections.try_join_next().is_some() {}
13297 if shared.shutdown.load(Ordering::Acquire) {
13298 return Ok(());
13299 }
13300 let preauth_permit = tokio::select! {
13301 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13302 permit.map_err(|_| NodeServerError::PreauthClosed)?
13303 }
13304 _ = shared.shutdown_notify.notified() => return Ok(()),
13305 };
13306 let server = tokio::select! {
13307 result = listener.accept() => result?,
13308 _ = shared.shutdown_notify.notified() => return Ok(()),
13309 };
13310 let connection_shared = Arc::clone(&shared);
13311 connections.spawn(async move {
13312 serve_connection(server, connection_shared, None, preauth_permit).await
13313 });
13314 if shared.shutdown.load(Ordering::Acquire) {
13315 return Ok(());
13316 }
13317 }
13318}
13319
13320async fn serve_connection<S>(
13328 mut pipe: S,
13329 shared: Arc<NodeShared>,
13330 session_token: Option<String>,
13331 preauth_permit: OwnedSemaphorePermit,
13332) -> Result<(), NodeServerError>
13333where
13334 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13335{
13336 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13338 let hello_frame = timeout(
13339 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13340 read_json_frame_limited_body_timeout(
13341 &mut pipe,
13342 MAX_NODE_HELLO_FRAME_BYTES,
13343 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13344 ),
13345 )
13346 .await
13347 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13348 let ClientFrame::Hello(hello) = hello_frame else {
13349 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13350 };
13351 if hello.build_stamp != BUILD_STAMP {
13352 return Err(NodeServerError::Handshake(format!(
13353 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13354 hello.build_stamp,
13355 )));
13356 }
13357 let compatibility = match hello.compatibility.as_ref() {
13358 Some(offer) => {
13359 let mut selected = node_compatibility_support(&shared)?
13360 .negotiate(offer)
13361 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13362 project_negotiated_provider_ids(&mut selected);
13363 Some(selected)
13364 }
13365 None => None,
13366 };
13367 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13368 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13369 (Some(offer), Some(selected)) => negotiated_auth_proof(
13370 auth_token,
13371 AuthDirection::Server,
13372 hello.role,
13373 &hello.client_nonce,
13374 &server_nonce,
13375 offer,
13376 selected,
13377 ),
13378 (None, None) => auth_proof(
13379 auth_token,
13380 AuthDirection::Server,
13381 hello.role,
13382 &hello.client_nonce,
13383 &server_nonce,
13384 ),
13385 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13386 }
13387 .map_err(NodeServerError::Authentication)?;
13388 write_json_frame_limited(
13389 &mut pipe,
13390 &ServerFrame::Challenge(ServerChallenge {
13391 build_stamp: BUILD_STAMP.to_owned(),
13392 server_nonce,
13393 server_proof,
13394 compatibility: compatibility.clone(),
13395 }),
13396 MAX_NODE_HELLO_FRAME_BYTES,
13397 )
13398 .await?;
13399 let authentication = timeout(
13400 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13401 read_json_frame_limited_body_timeout(
13402 &mut pipe,
13403 MAX_NODE_HELLO_FRAME_BYTES,
13404 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13405 ),
13406 )
13407 .await
13408 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13409 let ClientFrame::Authenticate(authentication) = authentication else {
13410 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13411 };
13412 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13413 (Some(offer), Some(selected)) => negotiated_auth_proof(
13414 auth_token,
13415 AuthDirection::Client,
13416 hello.role,
13417 &hello.client_nonce,
13418 &server_nonce,
13419 offer,
13420 selected,
13421 ),
13422 (None, None) => auth_proof(
13423 auth_token,
13424 AuthDirection::Client,
13425 hello.role,
13426 &hello.client_nonce,
13427 &server_nonce,
13428 ),
13429 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13430 }
13431 .map_err(NodeServerError::Authentication)?;
13432 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13433 return Err(NodeServerError::Handshake("access denied".to_owned()));
13434 }
13435 let selected_capabilities = compatibility
13436 .as_ref()
13437 .map(|selected| selected.capabilities.clone())
13438 .unwrap_or_default();
13439 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13440 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13441 });
13442 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13443 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13444 });
13445 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13446 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13447 });
13448 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13449 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13450 });
13451 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13452 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13453 });
13454 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13455 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13456 }) && selected_capabilities.iter().any(|capability| {
13457 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13458 });
13459 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13460 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13461 });
13462 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13463 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13464 });
13465 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13466 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13467 });
13468 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13469 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13470 });
13471 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13472 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13473 });
13474 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13475 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13476 });
13477 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13478 .try_acquire_owned()
13479 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13480 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13481 let _release_guard = ControllerReleaseGuard {
13482 shared: Arc::clone(&shared),
13483 connection_id,
13484 };
13485 drop(preauth_permit);
13486 let _authenticated_permit = authenticated_permit;
13487 let mut event_rx = shared.event_tx.subscribe();
13488 let mut terminal_event_rx = terminal_event_subscription(
13489 &shared,
13490 include_terminal_frame_events,
13491 );
13492 let mut agent_stream_event_rx = agent_stream_event_subscription(
13493 &shared,
13494 include_agent_stream_events,
13495 );
13496 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13497 &shared,
13498 include_harness_mcp_proxy,
13499 );
13500 write_json_frame(
13501 &mut pipe,
13502 &ServerFrame::Hello(NodeHello {
13503 build_stamp: BUILD_STAMP.to_owned(),
13504 incarnation_id: shared.incarnation_id,
13505 connection_id,
13506 role: hello.role,
13507 event_sequence: shared.current_sequence(),
13508 controller: shared.controller_state(),
13509 snapshot: snapshot_for_wire(
13510 &shared,
13511 include_provider_runtime_status,
13512 include_open_provider_ids,
13513 include_spawn_profiles,
13514 include_managed_worktrees,
13515 include_child_environment_profiles,
13516 include_session_bundles,
13517 include_history_context_packs,
13518 include_agent_progress,
13519 include_session_task_correlation,
13520 ),
13521 compatibility,
13522 }),
13523 )
13524 .await?;
13525 let (mut reader, mut writer) = tokio::io::split(pipe);
13526 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13527 let reader_task = tokio::spawn(async move {
13528 loop {
13529 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13530 &mut reader,
13531 MAX_NODE_CLIENT_FRAME_BYTES,
13532 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13533 )
13534 .await;
13535 let terminal = frame.is_err();
13536 if frame_tx.send(frame).await.is_err() || terminal {
13537 break;
13538 }
13539 }
13540 });
13541 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13542 let mut pending_events = Vec::new();
13543 let mut resync_required = false;
13544 let mut discard_events_through = 0_u64;
13545 loop {
13546 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13551 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13552 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13553 for _ in 0..durable_drain_budget {
13554 match event_rx.try_recv() {
13555 Ok(event) => queue_connection_event(
13556 &mut pending_events,
13557 event,
13558 discard_events_through,
13559 ),
13560 Err(broadcast::error::TryRecvError::Empty) => break,
13561 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13562 resync_required = true;
13563 break;
13564 }
13565 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13566 }
13567 }
13568 if let Some(receiver) = terminal_event_rx.as_mut() {
13569 match receiver.try_recv() {
13570 Ok(events) => queue_connection_event_batch(
13571 &mut pending_events,
13572 &events,
13573 discard_events_through,
13574 ),
13575 Err(broadcast::error::TryRecvError::Empty) => {}
13576 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13577 resync_required = true;
13578 }
13579 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13580 }
13581 }
13582 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13583 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13593 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13594 for _ in 0..agent_stream_drain_budget {
13595 match receiver.try_recv() {
13596 Ok(events) => queue_connection_event_batch(
13597 &mut pending_events,
13598 &events,
13599 discard_events_through,
13600 ),
13601 Err(broadcast::error::TryRecvError::Empty) => break,
13602 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13603 tracing::warn!(
13604 connection_id,
13605 dropped,
13606 "agent stream event subscription lagged; forcing a resync",
13607 );
13608 resync_required = true;
13609 break;
13610 }
13611 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13612 }
13613 }
13614 }
13615 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13616 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13622 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13623 for _ in 0..harness_mcp_proxy_drain_budget {
13624 match receiver.try_recv() {
13625 Ok(events) => queue_connection_event_batch(
13626 &mut pending_events,
13627 &events,
13628 discard_events_through,
13629 ),
13630 Err(broadcast::error::TryRecvError::Empty) => break,
13631 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13632 tracing::warn!(
13633 connection_id,
13634 dropped,
13635 "harness MCP proxy event subscription lagged; forcing a resync",
13636 );
13637 resync_required = true;
13638 break;
13639 }
13640 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13641 }
13642 }
13643 }
13644 if resync_required {
13645 let event = resync_required_event(&shared);
13646 apply_connection_resync_watermark(
13647 &mut pending_events,
13648 &mut discard_events_through,
13649 event.sequence,
13650 );
13651 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13652 resync_required = false;
13653 continue;
13654 }
13655 if !pending_events.is_empty() {
13656 pending_events.sort_unstable_by_key(|event| event.sequence);
13657 let burst_len = connection_event_burst_len(pending_events.len());
13658 shared
13659 .connection_events_sent
13660 .fetch_add(burst_len as u64, Ordering::Relaxed);
13661 for event in pending_events.drain(..burst_len) {
13662 let event = if include_managed_worktrees {
13663 Some(event)
13664 } else {
13665 project_event_without_managed_worktrees(event)
13666 };
13667 let event = event.and_then(|event| if include_open_provider_ids {
13668 Some(event)
13669 } else {
13670 project_event_legacy_provider_ids(&shared, event)
13671 });
13672 let event = event.map(|event| if include_child_environment_profiles {
13673 event
13674 } else {
13675 project_event_without_child_environment_profile(event)
13676 });
13677 let event = event.map(|event| if include_session_bundles {
13678 event
13679 } else {
13680 project_event_without_session_bundle(event)
13681 });
13682 let event = event.map(|event| {
13683 project_event_history_for_wire(event, include_history_context_packs)
13684 });
13685 let event = event.map(|event| if include_history_context_packs {
13686 event
13687 } else {
13688 project_event_without_context_pack(event)
13689 });
13690 let event = event.map(|event| if include_session_task_correlation {
13691 event
13692 } else {
13693 project_event_without_session_task_binding(event)
13694 });
13695 let exact_harness_controller = hello.role == ClientRole::Operator
13696 && shared.controller_state().is_some_and(|controller| {
13697 controller.connection_id == connection_id
13698 });
13699 let event = event.and_then(|event| {
13700 if include_harness_mcp_proxy && exact_harness_controller {
13701 Some(event)
13702 } else {
13703 project_event_without_harness_mcp_proxy(event)
13704 }
13705 });
13706 if let Some(event) = event {
13707 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13708 }
13709 }
13710 }
13711 tokio::select! {
13712 biased;
13713 frame = frame_rx.recv() => {
13714 let frame = match frame {
13715 Some(frame) => frame,
13716 None => break,
13717 };
13718 let frame = match frame {
13719 Ok(frame) => frame,
13720 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13721 Err(error) => return Err(error.into()),
13722 };
13723 let ClientFrame::Request(request) = frame else {
13724 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13725 };
13726 #[cfg(feature = "fixture")]
13727 let probe_spawn_managed_worktree_v2 = matches!(
13728 &request.request,
13729 NodeRequest::SpawnManagedWorktreeV2 { .. }
13730 );
13731 let requires_open_provider_ids =
13732 request_requires_open_provider_ids(&shared, &request.request);
13733 let requires_child_environment_profile =
13734 request_requires_child_environment_profile(&shared, &request.request);
13735 let requires_session_bundle =
13736 request_requires_session_bundle(&shared, &request.request);
13737 let requires_history_context_pack =
13738 request_requires_history_context_pack(&shared, &request.request);
13739 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13740 ResponseEnvelope {
13741 request_id: request.request_id,
13742 result: Err(failure(
13743 NodeFailureCode::UnsupportedCapability,
13744 "open provider IDs were not negotiated",
13745 )),
13746 }
13747 } else if requires_child_environment_profile
13748 && !include_child_environment_profiles
13749 {
13750 ResponseEnvelope {
13751 request_id: request.request_id,
13752 result: Err(failure(
13753 NodeFailureCode::UnsupportedCapability,
13754 "child environment profiles were not negotiated",
13755 )),
13756 }
13757 } else if requires_session_bundle && !include_session_bundles {
13758 ResponseEnvelope {
13759 request_id: request.request_id,
13760 result: Err(failure(
13761 NodeFailureCode::UnsupportedCapability,
13762 "session bundles were not negotiated",
13763 )),
13764 }
13765 } else if requires_history_context_pack && !include_history_context_packs {
13766 ResponseEnvelope {
13767 request_id: request.request_id,
13768 result: Err(failure(
13769 NodeFailureCode::UnsupportedCapability,
13770 "history context packs were not negotiated",
13771 )),
13772 }
13773 } else if request_uses_unnegotiated_capability(
13774 &request.request,
13775 &selected_capabilities,
13776 )
13777 {
13778 ResponseEnvelope {
13779 request_id: request.request_id,
13780 result: Err(failure(
13781 NodeFailureCode::UnsupportedCapability,
13782 "request capability was not negotiated",
13783 )),
13784 }
13785 } else {
13786 process_request(&shared, connection_id, hello.role, request).await
13787 };
13788 #[cfg(feature = "fixture")]
13789 if probe_spawn_managed_worktree_v2 {
13790 shared
13791 .fixture_spawn_managed_worktree_v2_failure_probe
13792 .record(reply.result.as_ref().err().map(|failure| failure.code));
13793 }
13794 if !include_provider_runtime_status {
13795 clear_response_provider_runtime_status(&mut reply);
13796 }
13797 if !include_managed_worktrees {
13798 project_response_without_managed_worktrees(&mut reply);
13799 }
13800 if !include_open_provider_ids {
13801 project_response_legacy_provider_ids(&shared, &mut reply);
13802 }
13803 if !include_child_environment_profiles {
13804 project_response_without_child_environment_profile(&mut reply);
13805 }
13806 if !include_session_bundles {
13807 project_response_without_session_bundle(&mut reply);
13808 }
13809 project_response_history_for_wire(
13810 &mut reply,
13811 include_history_context_packs,
13812 );
13813 if !include_history_context_packs {
13814 project_response_without_context_pack(&mut reply);
13815 }
13816 if !include_agent_progress {
13817 project_response_without_agent_progress(&mut reply);
13818 }
13819 if !include_session_task_correlation {
13820 project_response_without_session_task_binding(&mut reply);
13821 }
13822 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13823 }
13824 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13825 event = event_rx.recv() => {
13826 match event {
13827 Ok(event) => queue_connection_event(
13828 &mut pending_events,
13829 event,
13830 discard_events_through,
13831 ),
13832 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13833 Err(broadcast::error::RecvError::Closed) => break,
13834 }
13835 }
13836 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13837 match events {
13838 Ok(events) => queue_connection_event_batch(
13839 &mut pending_events,
13840 &events,
13841 discard_events_through,
13842 ),
13843 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13844 Err(broadcast::error::RecvError::Closed) => break,
13845 }
13846 }
13847 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13848 match events {
13849 Ok(events) => queue_connection_event_batch(
13850 &mut pending_events,
13851 &events,
13852 discard_events_through,
13853 ),
13854 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13855 Err(broadcast::error::RecvError::Closed) => break,
13856 }
13857 }
13858 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13859 match events {
13860 Ok(events) => queue_connection_event_batch(
13861 &mut pending_events,
13862 &events,
13863 discard_events_through,
13864 ),
13865 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13866 Err(broadcast::error::RecvError::Closed) => break,
13867 }
13868 }
13869 _ = shared.shutdown_notify.notified() => break,
13870 }
13871 }
13872 Ok(())
13873}
13874
13875async fn receive_terminal_event_batch(
13876 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13877) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13878 match receiver {
13879 Some(receiver) => receiver.recv().await,
13880 None => std::future::pending().await,
13881 }
13882}
13883
13884fn terminal_event_subscription(
13885 shared: &NodeShared,
13886 enabled: bool,
13887) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13888 enabled.then(|| shared.terminal_event_tx.subscribe())
13889}
13890
13891fn agent_stream_event_subscription(
13895 shared: &NodeShared,
13896 enabled: bool,
13897) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13898 enabled.then(|| shared.agent_stream_event_tx.subscribe())
13899}
13900
13901fn harness_mcp_proxy_event_subscription(
13906 shared: &NodeShared,
13907 enabled: bool,
13908) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13909 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
13910}
13911
13912fn queue_connection_event(
13913 pending: &mut Vec<NodeEventEnvelope>,
13914 event: NodeEventEnvelope,
13915 discard_through: u64,
13916) {
13917 if event.sequence <= discard_through
13918 && !event.event.requires_harness_mcp_proxy_capability()
13919 {
13920 return;
13921 }
13922 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
13923 if let Some(index) = pending.iter().position(|current| {
13924 matches!(
13925 ¤t.event,
13926 NodeEvent::TerminalFrame {
13927 address: current_address,
13928 ..
13929 } if current_address == address
13930 )
13931 }) {
13932 pending.remove(index);
13933 }
13934 }
13935 pending.push(event);
13936}
13937
13938fn project_event_without_harness_mcp_proxy(
13939 envelope: NodeEventEnvelope,
13940) -> Option<NodeEventEnvelope> {
13941 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
13942}
13943
13944fn queue_connection_event_batch(
13945 pending: &mut Vec<NodeEventEnvelope>,
13946 events: &[NodeEventEnvelope],
13947 discard_through: u64,
13948) {
13949 for event in events {
13950 queue_connection_event(pending, event.clone(), discard_through);
13951 }
13952}
13953
13954fn apply_connection_resync_watermark(
13955 pending: &mut Vec<NodeEventEnvelope>,
13956 discard_through: &mut u64,
13957 marker_sequence: u64,
13958) {
13959 *discard_through = (*discard_through).max(marker_sequence);
13960 pending.retain(|event| event.sequence > *discard_through);
13961}
13962
13963fn connection_event_burst_len(pending_len: usize) -> usize {
13964 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
13965}
13966
13967fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
13968 let history = shared
13969 .history
13970 .lock()
13971 .unwrap_or_else(|poisoned| poisoned.into_inner());
13972 let sequence = history.last_sequence;
13973 let oldest_available_sequence = history.replay_floor_sequence;
13974 NodeEventEnvelope {
13975 sequence,
13976 event: NodeEvent::ResyncRequired {
13977 oldest_available_sequence,
13978 },
13979 }
13980}
13981
13982fn node_compatibility_support(
13983 shared: &NodeShared,
13984) -> Result<NodeCompatibilitySupport, NodeServerError> {
13985 let mut support = node_compatibility_support_for_manifest(
13986 &shared.provider_contracts,
13987 &shared.provider_adapter_contracts,
13988 )?;
13989 if shared
13990 .delivery_store
13991 .lock()
13992 .unwrap_or_else(|poisoned| poisoned.into_inner())
13993 .is_some()
13994 {
13995 support.capabilities.push(
13996 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
13997 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
13998 );
13999 }
14000 if shared.harness_mcp_registry.is_some() {
14001 support.capabilities.push(
14002 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14003 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14004 );
14005 }
14006 Ok(support)
14007}
14008
14009fn node_compatibility_support_for_manifest(
14010 provider_contracts: &[ProviderContractSupport],
14011 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14012) -> Result<NodeCompatibilitySupport, NodeServerError> {
14013 Ok(NodeCompatibilitySupport {
14014 build_stamp: BUILD_STAMP.to_owned(),
14015 capabilities: baseline_capabilities()?,
14016 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14017 path_semantics: platform::path_semantics(),
14018 local_transport: platform::local_transport(),
14019 state_schema: StateSchemaSupport {
14020 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14021 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14022 },
14023 provider_contracts: provider_contracts.to_vec(),
14024 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14025 })
14026}
14027
14028fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14029 let capabilities = [
14030 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14031 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14032 NODE_REPOSITORY_PATH_CAPABILITY,
14033 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14034 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14035 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14036 NODE_GIT_READ_CAPABILITY,
14037 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14038 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14039 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14040 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14041 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14042 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14043 NODE_ACP_CONTROL_CAPABILITY,
14044 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14045 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14046 NODE_WORKTREE_SELECTION_CAPABILITY,
14047 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14048 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14049 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14050 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14051 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14052 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14053 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14054 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14055 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14056 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14057 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14058 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14059 ]
14060 .into_iter()
14061 .map(|capability| {
14062 CapabilityId::new(capability)
14063 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14064 })
14065 .collect::<Result<Vec<_>, _>>()?;
14066 #[cfg(windows)]
14067 let capabilities = {
14068 let mut capabilities = capabilities;
14069 capabilities.push(
14070 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14071 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14072 );
14073 capabilities
14074 };
14075 Ok(capabilities)
14076}
14077
14078fn request_uses_unnegotiated_capability(
14079 request: &NodeRequest,
14080 selected_capabilities: &[CapabilityId],
14081) -> bool {
14082 let selected = |required: &str| {
14083 selected_capabilities
14084 .iter()
14085 .any(|capability| capability.as_str() == required)
14086 };
14087 request
14088 .required_capability()
14089 .is_some_and(|required| !selected(required))
14090 || (request.requires_worktree_selection_capability()
14091 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14092 || (request.requires_spawn_spec_defaults_overrides_capability()
14093 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14094 || (request.requires_spawn_profile_revision_capability()
14095 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14096 || (request.requires_child_environment_profile_capability()
14097 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14098 || (request.requires_session_bundle_materialization_capability()
14099 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14100 || (request.requires_history_context_pack_capability()
14101 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14102}
14103
14104fn snapshot_for_wire(
14105 shared: &NodeShared,
14106 include_provider_runtime_status: bool,
14107 include_open_provider_ids: bool,
14108 include_spawn_profiles: bool,
14109 include_managed_worktrees: bool,
14110 include_child_environment_profiles: bool,
14111 include_session_bundles: bool,
14112 include_history_context_packs: bool,
14113 include_agent_progress: bool,
14114 include_session_task_correlation: bool,
14115) -> NodeSnapshot {
14116 let mut snapshot = shared.snapshot();
14117 if !include_provider_runtime_status {
14118 snapshot.provider_runtime_statuses.clear();
14119 }
14120 if !include_open_provider_ids {
14121 project_snapshot_legacy_provider_ids(&mut snapshot);
14122 }
14123 if !include_managed_worktrees {
14124 snapshot.managed_worktrees.clear();
14125 for workspace in &mut snapshot.workspaces {
14126 workspace.managed_worktree_profiles = None;
14127 }
14128 }
14129 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14130 if !include_spawn_profiles {
14131 inventory.spawn_profiles = None;
14132 }
14133 if !include_session_bundles {
14134 inventory.bundles = None;
14135 }
14136 if inventory.spawn_profiles.is_none()
14137 && inventory.bundles.is_none()
14138 && inventory.network_allowlists.is_none()
14139 {
14140 snapshot.launch_inventory = None;
14141 }
14142 }
14143 if !include_child_environment_profiles {
14144 clear_snapshot_child_environment_profiles(&mut snapshot);
14145 }
14146 if !include_session_bundles {
14147 clear_snapshot_session_bundles(&mut snapshot);
14148 }
14149 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14150 if !include_history_context_packs {
14151 clear_snapshot_context_packs(&mut snapshot);
14152 }
14153 if !include_agent_progress {
14154 snapshot.agent_progress.clear();
14155 }
14156 if !include_session_task_correlation {
14157 clear_snapshot_session_task_bindings(&mut snapshot);
14158 }
14159 snapshot
14160}
14161
14162fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14163 for record in &mut snapshot.session_records {
14164 record.task_binding = None;
14165 }
14166}
14167
14168fn project_event_without_session_task_binding(
14169 mut envelope: NodeEventEnvelope,
14170) -> NodeEventEnvelope {
14171 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14172 record.task_binding = None;
14173 }
14174 envelope
14175}
14176
14177fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14178 let Ok(response) = reply.result.as_mut() else { return };
14179 match response {
14180 NodeResponse::Snapshot { snapshot, .. } => {
14181 clear_snapshot_session_task_bindings(snapshot);
14182 }
14183 NodeResponse::Resync { snapshot, events, .. } => {
14184 clear_snapshot_session_task_bindings(snapshot);
14185 for event in events {
14186 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14187 record.task_binding = None;
14188 }
14189 }
14190 }
14191 NodeResponse::SessionRecordUpdated { record }
14192 | NodeResponse::ProviderSessionIndexed { record }
14193 | NodeResponse::NativeSessionIndexed { record, .. }
14194 | NodeResponse::SessionRecordResumed { record, .. } => {
14195 record.task_binding = None;
14196 }
14197 _ => {}
14198 }
14199}
14200
14201fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14202 let Ok(response) = reply.result.as_mut() else { return };
14203 match response {
14204 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14205 snapshot.agent_progress.clear();
14206 }
14207 _ => {}
14208 }
14209}
14210
14211fn project_event_without_managed_worktrees(
14212 mut envelope: NodeEventEnvelope,
14213) -> Option<NodeEventEnvelope> {
14214 if matches!(
14215 envelope.event,
14216 NodeEvent::ManagedWorktreeUpserted { .. }
14217 | NodeEvent::ManagedWorktreeRemoved { .. }
14218 ) {
14219 return None;
14220 }
14221 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14222 workspace.managed_worktree_profiles = None;
14223 }
14224 Some(envelope)
14225}
14226
14227fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14228 if matches!(
14229 reply.result,
14230 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14231 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14232 ) {
14233 reply.result = Err(failure(
14234 NodeFailureCode::UnsupportedCapability,
14235 "managed worktree capability was not negotiated",
14236 ));
14237 return;
14238 }
14239 let Ok(response) = reply.result.as_mut() else { return };
14240 match response {
14241 NodeResponse::Snapshot { snapshot, .. } => {
14242 snapshot.managed_worktrees.clear();
14243 for workspace in &mut snapshot.workspaces {
14244 workspace.managed_worktree_profiles = None;
14245 }
14246 }
14247 NodeResponse::Resync { snapshot, events, .. } => {
14248 snapshot.managed_worktrees.clear();
14249 for workspace in &mut snapshot.workspaces {
14250 workspace.managed_worktree_profiles = None;
14251 }
14252 *events = std::mem::take(events)
14253 .into_iter()
14254 .filter_map(project_event_without_managed_worktrees)
14255 .collect();
14256 }
14257 NodeResponse::WorkspaceRegistered { workspace }
14258 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14259 | NodeResponse::WorktreeCreated { workspace, .. } => {
14260 workspace.managed_worktree_profiles = None;
14261 }
14262 NodeResponse::WorkspaceInspected { inspection } => {
14263 inspection.git.managed_worktree = None;
14264 }
14265 _ => {}
14266 }
14267}
14268
14269fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14270 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14271 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14272 });
14273 if includes_open_provider_ids {
14274 return;
14275 }
14276 compatibility
14277 .provider_contracts
14278 .retain(|contract| provider_id_is_legacy(&contract.provider));
14279 compatibility
14280 .provider_adapter_contracts
14281 .retain(|contract| provider_id_is_legacy(&contract.provider));
14282}
14283
14284fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14285 if let Some(spec) = match request {
14286 NodeRequest::SpawnSpec { spec }
14287 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14288 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14289 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14290 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14291 _ => None,
14292 } {
14293 return shared
14294 .resolve_spawn_spec(spec)
14295 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14296 }
14297 request_requires_open_provider_ids_with(
14298 request,
14299 |session| shared.validate_address(session).ok(),
14300 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14301 )
14302}
14303
14304fn request_requires_child_environment_profile(
14305 shared: &NodeShared,
14306 request: &NodeRequest,
14307) -> bool {
14308 if let Some(spec) = match request {
14309 NodeRequest::SpawnSpec { spec }
14310 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14311 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14312 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14313 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14314 _ => None,
14315 } {
14316 return shared
14317 .resolve_spawn_spec(spec)
14318 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14319 }
14320 match request {
14321 NodeRequest::Resume { session, .. }
14322 | NodeRequest::Prompt { session, .. }
14323 | NodeRequest::Paste { session, .. }
14324 | NodeRequest::Input { session, .. }
14325 | NodeRequest::TerminalBytes { session, .. }
14326 | NodeRequest::TerminalControl { session, .. }
14327 | NodeRequest::Resize { session, .. }
14328 | NodeRequest::Interrupt { session }
14329 | NodeRequest::Stop { session, .. }
14330 | NodeRequest::Remove { session }
14331 | NodeRequest::ResolveInteraction { session, .. }
14332 | NodeRequest::SetSessionMode { session, .. }
14333 | NodeRequest::SetSessionConfigOption { session, .. }
14334 | NodeRequest::SetSessionModel { session, .. }
14335 | NodeRequest::DiscoverHistory { session, .. }
14336 | NodeRequest::LoadHistory { session, .. }
14337 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14338 | NodeRequest::ExportContextPack { session } => shared
14339 .session_bindings
14340 .lock()
14341 .unwrap_or_else(|poisoned| poisoned.into_inner())
14342 .get(&session.session.instance_id)
14343 .filter(|binding| {
14344 binding.workspace_id == session.workspace_id
14345 && binding.generation == session.session.generation
14346 })
14347 .map_or(true, |binding| binding.environment_profile.is_some()),
14348 NodeRequest::RenameSessionRecord { record_id, .. }
14349 | NodeRequest::SetSessionTask { record_id, .. }
14350 | NodeRequest::ResumeSessionRecord { record_id, .. }
14351 | NodeRequest::ForgetSessionRecord { record_id } => shared
14352 .record(record_id)
14353 .map_or(true, |record| record.environment_profile.is_some()),
14354 NodeRequest::ArmHarnessMcpReservation { .. }
14355 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14356 | NodeRequest::ActivateHarnessMcpReservation { .. }
14357 | NodeRequest::AbortHarnessMcpReservation { .. }
14358 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14359 | NodeRequest::RejectHarnessMcpCall { .. }
14360 | NodeRequest::Snapshot
14361 | NodeRequest::Resync { .. }
14362 | NodeRequest::BeginDeliveryStage { .. }
14363 | NodeRequest::PutDeliveryBlobChunk { .. }
14364 | NodeRequest::CommitDeliveryStage { .. }
14365 | NodeRequest::AbortDeliveryStage { .. }
14366 | NodeRequest::BrowseHostDirectories { .. }
14367 | NodeRequest::InspectWorkspace { .. }
14368 | NodeRequest::ReadWorkspaceFile { .. }
14369 | NodeRequest::WriteWorkspaceFile { .. }
14370 | NodeRequest::CreateWorkspaceFile { .. }
14371 | NodeRequest::CreateWorkspaceDirectory { .. }
14372 | NodeRequest::ReadGitHistory { .. }
14373 | NodeRequest::ReadGitDiff { .. }
14374 | NodeRequest::AcquireController { .. }
14375 | NodeRequest::ReleaseController
14376 | NodeRequest::Spawn { .. }
14377 | NodeRequest::SpawnWithOptions { .. }
14378 | NodeRequest::SpawnSpec { .. }
14379 | NodeRequest::RegisterWorkspace { .. }
14380 | NodeRequest::CreateStandaloneWorkspace { .. }
14381 | NodeRequest::UnregisterWorkspace { .. }
14382 | NodeRequest::CreateWorktree { .. }
14383 | NodeRequest::RemoveWorktree { .. }
14384 | NodeRequest::SpawnManagedWorktree { .. }
14385 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14386 | NodeRequest::CleanupManagedWorktree { .. }
14387 | NodeRequest::IndexProviderSession { .. }
14388 | NodeRequest::IndexNativeSession { .. }
14389 | NodeRequest::CatalogNativeSessions { .. }
14390 | NodeRequest::PageNativeSessions { .. }
14391 | NodeRequest::PreviewNativeSession { .. }
14392 | NodeRequest::PreviewSessionRecord { .. }
14393 | NodeRequest::ForgetContextPack { .. }
14394 | NodeRequest::ResolveDurableContextPack { .. }
14395 | NodeRequest::ReadContextPack { .. }
14396 | NodeRequest::Shutdown => false,
14397 }
14398}
14399
14400fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14401 if let Some(spec) = match request {
14402 NodeRequest::SpawnSpec { spec }
14403 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14404 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14405 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14406 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14407 _ => None,
14408 } {
14409 return shared
14410 .resolve_spawn_spec(spec)
14411 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14412 }
14413 match request {
14414 NodeRequest::Resume { session, .. }
14415 | NodeRequest::Prompt { session, .. }
14416 | NodeRequest::Paste { session, .. }
14417 | NodeRequest::Input { session, .. }
14418 | NodeRequest::TerminalBytes { session, .. }
14419 | NodeRequest::TerminalControl { session, .. }
14420 | NodeRequest::Resize { session, .. }
14421 | NodeRequest::Interrupt { session }
14422 | NodeRequest::Stop { session, .. }
14423 | NodeRequest::Remove { session }
14424 | NodeRequest::ResolveInteraction { session, .. }
14425 | NodeRequest::SetSessionMode { session, .. }
14426 | NodeRequest::SetSessionConfigOption { session, .. }
14427 | NodeRequest::SetSessionModel { session, .. }
14428 | NodeRequest::DiscoverHistory { session, .. }
14429 | NodeRequest::LoadHistory { session, .. }
14430 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14431 | NodeRequest::ExportContextPack { session } => shared
14432 .session_bindings
14433 .lock()
14434 .unwrap_or_else(|poisoned| poisoned.into_inner())
14435 .get(&session.session.instance_id)
14436 .filter(|binding| {
14437 binding.workspace_id == session.workspace_id
14438 && binding.generation == session.session.generation
14439 })
14440 .map_or(true, |binding| binding.bundle.is_some()),
14441 NodeRequest::RenameSessionRecord { record_id, .. }
14442 | NodeRequest::SetSessionTask { record_id, .. }
14443 | NodeRequest::ResumeSessionRecord { record_id, .. }
14444 | NodeRequest::ForgetSessionRecord { record_id } => shared
14445 .record(record_id)
14446 .map_or(true, |record| record.bundle.is_some()),
14447 NodeRequest::ArmHarnessMcpReservation { .. }
14448 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14449 | NodeRequest::ActivateHarnessMcpReservation { .. }
14450 | NodeRequest::AbortHarnessMcpReservation { .. }
14451 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14452 | NodeRequest::RejectHarnessMcpCall { .. }
14453 | NodeRequest::Snapshot
14454 | NodeRequest::Resync { .. }
14455 | NodeRequest::BeginDeliveryStage { .. }
14456 | NodeRequest::PutDeliveryBlobChunk { .. }
14457 | NodeRequest::CommitDeliveryStage { .. }
14458 | NodeRequest::AbortDeliveryStage { .. }
14459 | NodeRequest::BrowseHostDirectories { .. }
14460 | NodeRequest::InspectWorkspace { .. }
14461 | NodeRequest::ReadWorkspaceFile { .. }
14462 | NodeRequest::WriteWorkspaceFile { .. }
14463 | NodeRequest::CreateWorkspaceFile { .. }
14464 | NodeRequest::CreateWorkspaceDirectory { .. }
14465 | NodeRequest::ReadGitHistory { .. }
14466 | NodeRequest::ReadGitDiff { .. }
14467 | NodeRequest::AcquireController { .. }
14468 | NodeRequest::ReleaseController
14469 | NodeRequest::Spawn { .. }
14470 | NodeRequest::SpawnWithOptions { .. }
14471 | NodeRequest::SpawnSpec { .. }
14472 | NodeRequest::RegisterWorkspace { .. }
14473 | NodeRequest::CreateStandaloneWorkspace { .. }
14474 | NodeRequest::UnregisterWorkspace { .. }
14475 | NodeRequest::CreateWorktree { .. }
14476 | NodeRequest::RemoveWorktree { .. }
14477 | NodeRequest::SpawnManagedWorktree { .. }
14478 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14479 | NodeRequest::CleanupManagedWorktree { .. }
14480 | NodeRequest::IndexProviderSession { .. }
14481 | NodeRequest::IndexNativeSession { .. }
14482 | NodeRequest::CatalogNativeSessions { .. }
14483 | NodeRequest::PageNativeSessions { .. }
14484 | NodeRequest::PreviewNativeSession { .. }
14485 | NodeRequest::PreviewSessionRecord { .. }
14486 | NodeRequest::ForgetContextPack { .. }
14487 | NodeRequest::ResolveDurableContextPack { .. }
14488 | NodeRequest::ReadContextPack { .. }
14489 | NodeRequest::Shutdown => false,
14490 }
14491}
14492
14493fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14494 if request.requires_history_context_pack_capability() {
14495 return true;
14496 }
14497 if let Some(spec) = match request {
14498 NodeRequest::SpawnSpec { spec }
14499 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14500 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14501 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14502 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14503 _ => None,
14504 } {
14505 return shared
14506 .resolve_spawn_spec(spec)
14507 .map_or(true, |resolved| resolved.context_id.is_some());
14508 }
14509 match request {
14510 NodeRequest::Resume { session, .. }
14511 | NodeRequest::Prompt { session, .. }
14512 | NodeRequest::Paste { session, .. }
14513 | NodeRequest::Input { session, .. }
14514 | NodeRequest::TerminalBytes { session, .. }
14515 | NodeRequest::TerminalControl { session, .. }
14516 | NodeRequest::Resize { session, .. }
14517 | NodeRequest::Interrupt { session }
14518 | NodeRequest::Stop { session, .. }
14519 | NodeRequest::Remove { session }
14520 | NodeRequest::ResolveInteraction { session, .. }
14521 | NodeRequest::SetSessionMode { session, .. }
14522 | NodeRequest::SetSessionConfigOption { session, .. }
14523 | NodeRequest::SetSessionModel { session, .. } => shared
14524 .session_bindings
14525 .lock()
14526 .unwrap_or_else(|poisoned| poisoned.into_inner())
14527 .get(&session.session.instance_id)
14528 .filter(|binding| {
14529 binding.workspace_id == session.workspace_id
14530 && binding.generation == session.session.generation
14531 })
14532 .map_or(true, |binding| binding.context.is_some()),
14533 NodeRequest::RenameSessionRecord { record_id, .. }
14534 | NodeRequest::SetSessionTask { record_id, .. }
14535 | NodeRequest::ResumeSessionRecord { record_id, .. }
14536 | NodeRequest::ForgetSessionRecord { record_id } => shared
14537 .record(record_id)
14538 .map_or(true, |record| record.context.is_some()),
14539 NodeRequest::ArmHarnessMcpReservation { .. }
14540 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14541 | NodeRequest::ActivateHarnessMcpReservation { .. }
14542 | NodeRequest::AbortHarnessMcpReservation { .. }
14543 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14544 | NodeRequest::RejectHarnessMcpCall { .. }
14545 | NodeRequest::Snapshot
14546 | NodeRequest::Resync { .. }
14547 | NodeRequest::BeginDeliveryStage { .. }
14548 | NodeRequest::PutDeliveryBlobChunk { .. }
14549 | NodeRequest::CommitDeliveryStage { .. }
14550 | NodeRequest::AbortDeliveryStage { .. }
14551 | NodeRequest::BrowseHostDirectories { .. }
14552 | NodeRequest::InspectWorkspace { .. }
14553 | NodeRequest::ReadWorkspaceFile { .. }
14554 | NodeRequest::WriteWorkspaceFile { .. }
14555 | NodeRequest::CreateWorkspaceFile { .. }
14556 | NodeRequest::CreateWorkspaceDirectory { .. }
14557 | NodeRequest::ReadGitHistory { .. }
14558 | NodeRequest::ReadGitDiff { .. }
14559 | NodeRequest::AcquireController { .. }
14560 | NodeRequest::ReleaseController
14561 | NodeRequest::Spawn { .. }
14562 | NodeRequest::SpawnWithOptions { .. }
14563 | NodeRequest::SpawnSpec { .. }
14564 | NodeRequest::RegisterWorkspace { .. }
14565 | NodeRequest::CreateStandaloneWorkspace { .. }
14566 | NodeRequest::UnregisterWorkspace { .. }
14567 | NodeRequest::CreateWorktree { .. }
14568 | NodeRequest::RemoveWorktree { .. }
14569 | NodeRequest::SpawnManagedWorktree { .. }
14570 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14571 | NodeRequest::CleanupManagedWorktree { .. }
14572 | NodeRequest::IndexProviderSession { .. }
14573 | NodeRequest::IndexNativeSession { .. }
14574 | NodeRequest::CatalogNativeSessions { .. }
14575 | NodeRequest::PageNativeSessions { .. }
14576 | NodeRequest::PreviewNativeSession { .. }
14577 | NodeRequest::PreviewSessionRecord { .. }
14578 | NodeRequest::DiscoverHistory { .. }
14579 | NodeRequest::LoadHistory { .. }
14580 | NodeRequest::ExportContextPackForSessionRecord { .. }
14581 | NodeRequest::ExportContextPack { .. }
14582 | NodeRequest::ForgetContextPack { .. }
14583 | NodeRequest::ResolveDurableContextPack { .. }
14584 | NodeRequest::ReadContextPack { .. }
14585 | NodeRequest::Shutdown => false,
14586 }
14587}
14588
14589fn request_requires_open_provider_ids_with(
14590 request: &NodeRequest,
14591 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14592 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14593) -> bool {
14594 match request {
14595 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14596 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14597 NodeRequest::CatalogNativeSessions { route, .. }
14598 | NodeRequest::PageNativeSessions { route, .. } => {
14599 !provider_id_is_legacy(&route.provider)
14600 }
14601 NodeRequest::PreviewNativeSession { selection, .. }
14602 | NodeRequest::IndexNativeSession { selection, .. } => {
14603 !provider_id_is_legacy(&selection.route.provider)
14604 }
14605 NodeRequest::SpawnSpec { .. }
14606 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14607 NodeRequest::SpawnManagedWorktree { .. }
14608 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14609 NodeRequest::Resume { session, .. }
14610 | NodeRequest::Prompt { session, .. }
14611 | NodeRequest::Paste { session, .. }
14612 | NodeRequest::Input { session, .. }
14613 | NodeRequest::TerminalBytes { session, .. }
14614 | NodeRequest::TerminalControl { session, .. }
14615 | NodeRequest::Resize { session, .. }
14616 | NodeRequest::Interrupt { session }
14617 | NodeRequest::Stop { session, .. }
14618 | NodeRequest::Remove { session }
14619 | NodeRequest::ResolveInteraction { session, .. }
14620 | NodeRequest::SetSessionMode { session, .. }
14621 | NodeRequest::SetSessionConfigOption { session, .. }
14622 | NodeRequest::SetSessionModel { session, .. }
14623 | NodeRequest::DiscoverHistory { session, .. }
14624 | NodeRequest::LoadHistory { session, .. }
14625 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14626 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14627 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14628 NodeRequest::RenameSessionRecord { record_id, .. }
14629 | NodeRequest::SetSessionTask { record_id, .. }
14630 | NodeRequest::ResumeSessionRecord { record_id, .. }
14631 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14632 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14633 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14634 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14635 NodeRequest::ArmHarnessMcpReservation { .. }
14636 | NodeRequest::ActivateHarnessMcpReservation { .. }
14637 | NodeRequest::AbortHarnessMcpReservation { .. }
14638 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14639 | NodeRequest::RejectHarnessMcpCall { .. }
14640 | NodeRequest::Snapshot
14641 | NodeRequest::Resync { .. }
14642 | NodeRequest::BeginDeliveryStage { .. }
14643 | NodeRequest::PutDeliveryBlobChunk { .. }
14644 | NodeRequest::CommitDeliveryStage { .. }
14645 | NodeRequest::AbortDeliveryStage { .. }
14646 | NodeRequest::BrowseHostDirectories { .. }
14647 | NodeRequest::InspectWorkspace { .. }
14648 | NodeRequest::ReadWorkspaceFile { .. }
14649 | NodeRequest::WriteWorkspaceFile { .. }
14650 | NodeRequest::CreateWorkspaceFile { .. }
14651 | NodeRequest::CreateWorkspaceDirectory { .. }
14652 | NodeRequest::ReadGitHistory { .. }
14653 | NodeRequest::ReadGitDiff { .. }
14654 | NodeRequest::AcquireController { .. }
14655 | NodeRequest::ReleaseController
14656 | NodeRequest::RegisterWorkspace { .. }
14657 | NodeRequest::CreateStandaloneWorkspace { .. }
14658 | NodeRequest::UnregisterWorkspace { .. }
14659 | NodeRequest::CreateWorktree { .. }
14660 | NodeRequest::RemoveWorktree { .. }
14661 | NodeRequest::CleanupManagedWorktree { .. }
14662 | NodeRequest::Shutdown => false,
14663 NodeRequest::ForgetContextPack { .. } => true,
14664 NodeRequest::ResolveDurableContextPack { .. }
14670 | NodeRequest::ReadContextPack { .. } => true,
14671 }
14672}
14673
14674fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14675 snapshot.enabled_providers.retain(provider_id_is_legacy);
14676 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14677 snapshot
14678 .provider_runtime_statuses
14679 .as_slice()
14680 .iter()
14681 .filter(|status| provider_id_is_legacy(status.provider()))
14682 .cloned(),
14683 )
14684 .expect("a filtered provider runtime projection remains valid");
14685 snapshot
14686 .session_records
14687 .retain(|record| provider_id_is_legacy(&record.provider));
14688 for workspace in &mut snapshot.workspaces {
14689 workspace
14690 .sessions
14691 .retain(|session| provider_id_is_legacy(&session.agent_id));
14692 }
14693}
14694
14695fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14696 for record in &mut snapshot.session_records {
14697 record.environment_profile = None;
14698 }
14699 if let Some(profiles) = snapshot
14700 .launch_inventory
14701 .as_mut()
14702 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14703 {
14704 for profile in profiles {
14705 profile.environment_profile = None;
14706 }
14707 }
14708}
14709
14710fn project_event_without_child_environment_profile(
14711 mut envelope: NodeEventEnvelope,
14712) -> NodeEventEnvelope {
14713 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14714 record.environment_profile = None;
14715 }
14716 envelope
14717}
14718
14719fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14720 let contains_environment_profile = match reply.result.as_ref() {
14721 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14722 receipt.environment_profile.is_some()
14723 }
14724 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14725 receipt.spawn.environment_profile.is_some()
14726 }
14727 _ => false,
14728 };
14729 if contains_environment_profile {
14730 reply.result = Err(failure(
14731 NodeFailureCode::UnsupportedCapability,
14732 "child environment profiles were not negotiated",
14733 ));
14734 return;
14735 }
14736 let Ok(response) = reply.result.as_mut() else {
14737 return;
14738 };
14739 match response {
14740 NodeResponse::Snapshot { snapshot, .. } => {
14741 clear_snapshot_child_environment_profiles(snapshot);
14742 }
14743 NodeResponse::Resync { snapshot, events, .. } => {
14744 clear_snapshot_child_environment_profiles(snapshot);
14745 for event in events {
14746 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14747 record.environment_profile = None;
14748 }
14749 }
14750 }
14751 NodeResponse::SessionRecordUpdated { record }
14752 | NodeResponse::ProviderSessionIndexed { record }
14753 | NodeResponse::NativeSessionIndexed { record, .. }
14754 | NodeResponse::SessionRecordResumed { record, .. } => {
14755 record.environment_profile = None;
14756 }
14757 NodeResponse::Armed { .. }
14758 | NodeResponse::Spawned { .. }
14759 | NodeResponse::Activated { .. }
14760 | NodeResponse::Aborted { .. }
14761 | NodeResponse::ReplyChunkAccepted { .. }
14762 | NodeResponse::CallRejected { .. }
14763 | NodeResponse::DeliveryStageBegun { .. }
14764 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14765 | NodeResponse::DeliveryCommitted { .. }
14766 | NodeResponse::DeliveryStageAborted { .. }
14767 | NodeResponse::WorkspaceInspected { .. }
14768 | NodeResponse::HostDirectoriesBrowsed { .. }
14769 | NodeResponse::WorkspaceFileRead { .. }
14770 | NodeResponse::WorkspaceFileWritten { .. }
14771 | NodeResponse::WorkspaceFileCreated { .. }
14772 | NodeResponse::WorkspaceDirectoryCreated { .. }
14773 | NodeResponse::GitHistoryRead { .. }
14774 | NodeResponse::GitDiffRead { .. }
14775 | NodeResponse::Controller { .. }
14776 | NodeResponse::SpawnAccepted { .. }
14777 | NodeResponse::SpawnSpecAccepted { .. }
14778 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14779 | NodeResponse::ManagedWorktreeCleanup { .. }
14780 | NodeResponse::NativeSessionsCataloged { .. }
14781 | NodeResponse::NativeSessionsPaged { .. }
14782 | NodeResponse::NativeSessionPreviewed { .. }
14783 | NodeResponse::SessionRecordPreviewed { .. }
14784 | NodeResponse::HistoryDiscovered { .. }
14785 | NodeResponse::HistoryLoaded { .. }
14786 | NodeResponse::ContextPackForSessionRecordExported { .. }
14787 | NodeResponse::ContextPackExported { .. }
14788 | NodeResponse::ContextPackForgotten { .. }
14789 | NodeResponse::DurableContextPackResolved { .. }
14790 | NodeResponse::ContextPackBytesRead { .. }
14791 | NodeResponse::SessionRecordForgotten { .. }
14792 | NodeResponse::WorkspaceRegistered { .. }
14793 | NodeResponse::StandaloneWorkspaceCreated { .. }
14794 | NodeResponse::WorkspaceUnregistered { .. }
14795 | NodeResponse::WorktreeCreated { .. }
14796 | NodeResponse::WorktreeRemoved { .. }
14797 | NodeResponse::Accepted
14798 | NodeResponse::ShuttingDown => {}
14799 }
14800}
14801
14802fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14803 for record in &mut snapshot.session_records {
14804 record.bundle = None;
14805 }
14806}
14807
14808fn project_event_without_session_bundle(
14809 mut envelope: NodeEventEnvelope,
14810) -> NodeEventEnvelope {
14811 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14812 record.bundle = None;
14813 }
14814 envelope
14815}
14816
14817fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14818 let contains_bundle = match reply.result.as_ref() {
14819 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14820 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14821 receipt.spawn.bundle.is_some()
14822 }
14823 _ => false,
14824 };
14825 if contains_bundle {
14826 reply.result = Err(failure(
14827 NodeFailureCode::UnsupportedCapability,
14828 "session bundles were not negotiated",
14829 ));
14830 return;
14831 }
14832 let Ok(response) = reply.result.as_mut() else {
14833 return;
14834 };
14835 match response {
14836 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14837 NodeResponse::Resync { snapshot, events, .. } => {
14838 clear_snapshot_session_bundles(snapshot);
14839 for event in events {
14840 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14841 record.bundle = None;
14842 }
14843 }
14844 }
14845 NodeResponse::SessionRecordUpdated { record }
14846 | NodeResponse::ProviderSessionIndexed { record }
14847 | NodeResponse::NativeSessionIndexed { record, .. }
14848 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14849 NodeResponse::Armed { .. }
14850 | NodeResponse::Spawned { .. }
14851 | NodeResponse::Activated { .. }
14852 | NodeResponse::Aborted { .. }
14853 | NodeResponse::ReplyChunkAccepted { .. }
14854 | NodeResponse::CallRejected { .. }
14855 | NodeResponse::DeliveryStageBegun { .. }
14856 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14857 | NodeResponse::DeliveryCommitted { .. }
14858 | NodeResponse::DeliveryStageAborted { .. }
14859 | NodeResponse::WorkspaceInspected { .. }
14860 | NodeResponse::HostDirectoriesBrowsed { .. }
14861 | NodeResponse::WorkspaceFileRead { .. }
14862 | NodeResponse::WorkspaceFileWritten { .. }
14863 | NodeResponse::WorkspaceFileCreated { .. }
14864 | NodeResponse::WorkspaceDirectoryCreated { .. }
14865 | NodeResponse::GitHistoryRead { .. }
14866 | NodeResponse::GitDiffRead { .. }
14867 | NodeResponse::Controller { .. }
14868 | NodeResponse::SpawnAccepted { .. }
14869 | NodeResponse::SpawnSpecAccepted { .. }
14870 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14871 | NodeResponse::ManagedWorktreeCleanup { .. }
14872 | NodeResponse::NativeSessionsCataloged { .. }
14873 | NodeResponse::NativeSessionsPaged { .. }
14874 | NodeResponse::NativeSessionPreviewed { .. }
14875 | NodeResponse::SessionRecordPreviewed { .. }
14876 | NodeResponse::HistoryDiscovered { .. }
14877 | NodeResponse::HistoryLoaded { .. }
14878 | NodeResponse::ContextPackForSessionRecordExported { .. }
14879 | NodeResponse::ContextPackExported { .. }
14880 | NodeResponse::ContextPackForgotten { .. }
14881 | NodeResponse::DurableContextPackResolved { .. }
14882 | NodeResponse::ContextPackBytesRead { .. }
14883 | NodeResponse::SessionRecordForgotten { .. }
14884 | NodeResponse::WorkspaceRegistered { .. }
14885 | NodeResponse::StandaloneWorkspaceCreated { .. }
14886 | NodeResponse::WorkspaceUnregistered { .. }
14887 | NodeResponse::WorktreeCreated { .. }
14888 | NodeResponse::WorktreeRemoved { .. }
14889 | NodeResponse::Accepted
14890 | NodeResponse::ShuttingDown => {}
14891 }
14892}
14893
14894fn project_snapshot_history_for_wire(
14895 snapshot: &mut NodeSnapshot,
14896 include_history_metadata: bool,
14897) {
14898 for workspace in &mut snapshot.workspaces {
14899 for session in &mut workspace.sessions {
14900 if include_history_metadata {
14901 session.history.loaded = None;
14902 } else {
14903 session.history = Default::default();
14904 }
14905 }
14906 }
14907}
14908
14909fn project_event_history_for_wire(
14910 mut envelope: NodeEventEnvelope,
14911 include_history_metadata: bool,
14912) -> NodeEventEnvelope {
14913 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14914 for session in &mut workspace.sessions {
14915 if include_history_metadata {
14916 session.history.loaded = None;
14917 } else {
14918 session.history = Default::default();
14919 }
14920 }
14921 }
14922 envelope
14923}
14924
14925fn project_response_history_for_wire(
14926 reply: &mut ResponseEnvelope,
14927 include_history_metadata: bool,
14928) {
14929 let Ok(response) = reply.result.as_mut() else {
14930 return;
14931 };
14932 match response {
14933 NodeResponse::Snapshot { snapshot, .. } => {
14934 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14935 }
14936 NodeResponse::Resync {
14937 snapshot, events, ..
14938 } => {
14939 project_snapshot_history_for_wire(snapshot, include_history_metadata);
14940 for event in events {
14941 *event = project_event_history_for_wire(
14942 event.clone(),
14943 include_history_metadata,
14944 );
14945 }
14946 }
14947 NodeResponse::WorkspaceRegistered { workspace }
14948 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14949 | NodeResponse::WorktreeCreated { workspace, .. } => {
14950 for session in &mut workspace.sessions {
14951 if include_history_metadata {
14952 session.history.loaded = None;
14953 } else {
14954 session.history = Default::default();
14955 }
14956 }
14957 }
14958 _ => {}
14959 }
14960}
14961
14962fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
14963 for record in &mut snapshot.session_records {
14964 record.context_id = None;
14965 record.context = None;
14966 record.exported_context = None;
14967 }
14968}
14969
14970fn project_event_without_context_pack(
14971 mut envelope: NodeEventEnvelope,
14972) -> NodeEventEnvelope {
14973 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14974 record.context_id = None;
14975 record.context = None;
14976 record.exported_context = None;
14977 }
14978 envelope
14979}
14980
14981fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
14982 let contains_context = match reply.result.as_ref() {
14983 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
14984 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14985 receipt.spawn.context.is_some()
14986 }
14987 Ok(NodeResponse::HistoryDiscovered { .. })
14988 | Ok(NodeResponse::HistoryLoaded { .. })
14989 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
14990 | Ok(NodeResponse::ContextPackExported { .. })
14991 | Ok(NodeResponse::ContextPackForgotten { .. })
14992 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
14993 _ => false,
14994 };
14995 if contains_context {
14996 reply.result = Err(failure(
14997 NodeFailureCode::UnsupportedCapability,
14998 "history context packs were not negotiated",
14999 ));
15000 return;
15001 }
15002 let Ok(response) = reply.result.as_mut() else {
15003 return;
15004 };
15005 match response {
15006 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15007 NodeResponse::Resync {
15008 snapshot, events, ..
15009 } => {
15010 clear_snapshot_context_packs(snapshot);
15011 for event in events {
15012 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15013 record.context_id = None;
15014 record.context = None;
15015 record.exported_context = None;
15016 }
15017 }
15018 }
15019 NodeResponse::SessionRecordUpdated { record }
15020 | NodeResponse::ProviderSessionIndexed { record }
15021 | NodeResponse::NativeSessionIndexed { record, .. }
15022 | NodeResponse::SessionRecordResumed { record, .. } => {
15023 record.context_id = None;
15024 record.context = None;
15025 record.exported_context = None;
15026 }
15027 _ => {}
15028 }
15029}
15030
15031fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15032 let contains_open_record = match reply.result.as_ref() {
15033 Ok(NodeResponse::SessionRecordUpdated { record })
15034 | Ok(NodeResponse::ProviderSessionIndexed { record })
15035 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15036 !provider_id_is_legacy(&record.provider)
15037 }
15038 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15039 !provider_id_is_legacy(&selection.route.provider)
15040 || !provider_id_is_legacy(&record.provider)
15041 }
15042 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15043 !provider_id_is_legacy(&receipt.provider)
15044 }
15045 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15046 !provider_id_is_legacy(&receipt.spawn.provider)
15047 }
15048 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15049 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15050 !provider_id_is_legacy(&route.provider)
15051 }
15052 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15053 !provider_id_is_legacy(&selection.route.provider)
15054 }
15055 _ => false,
15056 };
15057 if contains_open_record {
15058 reply.result = Err(failure(
15059 NodeFailureCode::UnsupportedCapability,
15060 "open provider IDs were not negotiated",
15061 ));
15062 return;
15063 }
15064 let Ok(response) = reply.result.as_mut() else {
15065 return;
15066 };
15067 match response {
15068 NodeResponse::Snapshot { snapshot, .. } => {
15069 project_snapshot_legacy_provider_ids(snapshot);
15070 }
15071 NodeResponse::Resync { snapshot, events, .. } => {
15072 project_snapshot_legacy_provider_ids(snapshot);
15073 *events = std::mem::take(events)
15074 .into_iter()
15075 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15076 .collect();
15077 }
15078 NodeResponse::WorkspaceRegistered { workspace }
15079 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15080 | NodeResponse::WorktreeCreated { workspace, .. } => {
15081 workspace
15082 .sessions
15083 .retain(|session| provider_id_is_legacy(&session.agent_id));
15084 }
15085 NodeResponse::Armed { .. }
15086 | NodeResponse::Spawned { .. }
15087 | NodeResponse::Activated { .. }
15088 | NodeResponse::Aborted { .. }
15089 | NodeResponse::ReplyChunkAccepted { .. }
15090 | NodeResponse::CallRejected { .. }
15091 | NodeResponse::DeliveryStageBegun { .. }
15092 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15093 | NodeResponse::DeliveryCommitted { .. }
15094 | NodeResponse::DeliveryStageAborted { .. }
15095 | NodeResponse::WorkspaceInspected { .. }
15096 | NodeResponse::HostDirectoriesBrowsed { .. }
15097 | NodeResponse::WorkspaceFileRead { .. }
15098 | NodeResponse::WorkspaceFileWritten { .. }
15099 | NodeResponse::WorkspaceFileCreated { .. }
15100 | NodeResponse::WorkspaceDirectoryCreated { .. }
15101 | NodeResponse::GitHistoryRead { .. }
15102 | NodeResponse::GitDiffRead { .. }
15103 | NodeResponse::Controller { .. }
15104 | NodeResponse::SpawnAccepted { .. }
15105 | NodeResponse::SpawnSpecAccepted { .. }
15106 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15107 | NodeResponse::ManagedWorktreeCleanup { .. }
15108 | NodeResponse::NativeSessionsCataloged { .. }
15109 | NodeResponse::NativeSessionsPaged { .. }
15110 | NodeResponse::NativeSessionPreviewed { .. }
15111 | NodeResponse::SessionRecordPreviewed { .. }
15112 | NodeResponse::HistoryDiscovered { .. }
15113 | NodeResponse::HistoryLoaded { .. }
15114 | NodeResponse::ContextPackForSessionRecordExported { .. }
15115 | NodeResponse::ContextPackExported { .. }
15116 | NodeResponse::ContextPackForgotten { .. }
15117 | NodeResponse::DurableContextPackResolved { .. }
15118 | NodeResponse::ContextPackBytesRead { .. }
15119 | NodeResponse::SessionRecordUpdated { .. }
15120 | NodeResponse::ProviderSessionIndexed { .. }
15121 | NodeResponse::NativeSessionIndexed { .. }
15122 | NodeResponse::SessionRecordResumed { .. }
15123 | NodeResponse::SessionRecordForgotten { .. }
15124 | NodeResponse::WorkspaceUnregistered { .. }
15125 | NodeResponse::WorktreeRemoved { .. }
15126 | NodeResponse::Accepted
15127 | NodeResponse::ShuttingDown => {}
15128 }
15129}
15130
15131fn project_event_legacy_provider_ids(
15132 shared: &NodeShared,
15133 mut envelope: NodeEventEnvelope,
15134) -> Option<NodeEventEnvelope> {
15135 let include = match &mut envelope.event {
15136 NodeEvent::HarnessMcpReadCall { .. } => true,
15137 NodeEvent::Control { address, .. }
15138 | NodeEvent::AgentStream { address, .. }
15139 | NodeEvent::TerminalFrame { address, .. } => shared
15140 .validate_address(address)
15141 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15142 NodeEvent::WorkspaceAdded { workspace } => {
15143 workspace
15144 .sessions
15145 .retain(|session| provider_id_is_legacy(&session.agent_id));
15146 true
15147 }
15148 NodeEvent::SessionRecordUpserted { record } => {
15149 provider_id_is_legacy(&record.provider)
15150 }
15151 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15152 NodeEvent::SessionRecordRemoved { .. } => shared
15153 .history
15154 .lock()
15155 .unwrap_or_else(|poisoned| poisoned.into_inner())
15156 .removed_record_providers
15157 .get(&envelope.sequence)
15158 .is_some_and(provider_id_is_legacy),
15159 NodeEvent::ControllerChanged { .. }
15160 | NodeEvent::ManagedWorktreeUpserted { .. }
15161 | NodeEvent::ManagedWorktreeRemoved { .. }
15162 | NodeEvent::WorkspaceRemoved { .. }
15163 | NodeEvent::ResyncRequired { .. } => true,
15164 };
15165 include.then_some(envelope)
15166}
15167
15168fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15169 let Ok(response) = reply.result.as_mut() else {
15170 return;
15171 };
15172 match response {
15173 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15174 snapshot.provider_runtime_statuses.clear();
15175 }
15176 NodeResponse::Armed { .. }
15177 | NodeResponse::Spawned { .. }
15178 | NodeResponse::Activated { .. }
15179 | NodeResponse::Aborted { .. }
15180 | NodeResponse::ReplyChunkAccepted { .. }
15181 | NodeResponse::CallRejected { .. }
15182 | NodeResponse::DeliveryStageBegun { .. }
15183 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15184 | NodeResponse::DeliveryCommitted { .. }
15185 | NodeResponse::DeliveryStageAborted { .. }
15186 | NodeResponse::WorkspaceInspected { .. }
15187 | NodeResponse::HostDirectoriesBrowsed { .. }
15188 | NodeResponse::WorkspaceFileRead { .. }
15189 | NodeResponse::WorkspaceFileWritten { .. }
15190 | NodeResponse::WorkspaceFileCreated { .. }
15191 | NodeResponse::WorkspaceDirectoryCreated { .. }
15192 | NodeResponse::GitHistoryRead { .. }
15193 | NodeResponse::GitDiffRead { .. }
15194 | NodeResponse::Controller { .. }
15195 | NodeResponse::SpawnAccepted { .. }
15196 | NodeResponse::SpawnSpecAccepted { .. }
15197 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15198 | NodeResponse::ManagedWorktreeCleanup { .. }
15199 | NodeResponse::NativeSessionsCataloged { .. }
15200 | NodeResponse::NativeSessionsPaged { .. }
15201 | NodeResponse::NativeSessionPreviewed { .. }
15202 | NodeResponse::SessionRecordPreviewed { .. }
15203 | NodeResponse::HistoryDiscovered { .. }
15204 | NodeResponse::HistoryLoaded { .. }
15205 | NodeResponse::ContextPackForSessionRecordExported { .. }
15206 | NodeResponse::ContextPackExported { .. }
15207 | NodeResponse::ContextPackForgotten { .. }
15208 | NodeResponse::DurableContextPackResolved { .. }
15209 | NodeResponse::ContextPackBytesRead { .. }
15210 | NodeResponse::SessionRecordUpdated { .. }
15211 | NodeResponse::ProviderSessionIndexed { .. }
15212 | NodeResponse::NativeSessionIndexed { .. }
15213 | NodeResponse::SessionRecordResumed { .. }
15214 | NodeResponse::SessionRecordForgotten { .. }
15215 | NodeResponse::WorkspaceRegistered { .. }
15216 | NodeResponse::StandaloneWorkspaceCreated { .. }
15217 | NodeResponse::WorkspaceUnregistered { .. }
15218 | NodeResponse::WorktreeCreated { .. }
15219 | NodeResponse::WorktreeRemoved { .. }
15220 | NodeResponse::Accepted
15221 | NodeResponse::ShuttingDown => {}
15222 }
15223}
15224
15225struct ControllerReleaseGuard {
15226 shared: Arc<NodeShared>,
15227 connection_id: u64,
15228}
15229
15230impl Drop for ControllerReleaseGuard {
15231 fn drop(&mut self) {
15232 self.shared.release_controller(self.connection_id);
15233 }
15234}
15235
15236struct AbortTaskOnDrop(AbortHandle);
15237
15238impl Drop for AbortTaskOnDrop {
15239 fn drop(&mut self) {
15240 self.0.abort();
15241 }
15242}
15243
15244fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15249 std::env::var(name)
15250 .ok()
15251 .and_then(|value| value.parse::<u64>().ok())
15252 .filter(|value| *value > 0)
15253 .unwrap_or(default)
15254 .min(max)
15255}
15256
15257fn workspace_inspection_time_budget_ms() -> u64 {
15258 env_config_bounded(
15259 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15260 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15261 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15262 )
15263}
15264
15265fn workspace_inspection_entry_cap() -> usize {
15266 env_config_bounded(
15267 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15268 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15269 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15270 ) as usize
15271}
15272
15273struct WorkspaceWalkBudget {
15279 deadline: Instant,
15280 entry_cap: usize,
15281 entries_visited: u64,
15282 time_budget_exceeded: bool,
15283 entry_cap_exceeded: bool,
15284}
15285
15286impl WorkspaceWalkBudget {
15287 fn new(deadline: Instant, entry_cap: usize) -> Self {
15288 Self {
15289 deadline,
15290 entry_cap,
15291 entries_visited: 0,
15292 time_budget_exceeded: false,
15293 entry_cap_exceeded: false,
15294 }
15295 }
15296
15297 fn is_exhausted(&self) -> bool {
15298 self.time_budget_exceeded || self.entry_cap_exceeded
15299 }
15300
15301 fn record_visit(&mut self) -> bool {
15304 self.entries_visited += 1;
15305 if self.entries_visited as usize >= self.entry_cap {
15306 self.entry_cap_exceeded = true;
15307 }
15308 if Instant::now() >= self.deadline {
15309 self.time_budget_exceeded = true;
15310 }
15311 self.is_exhausted()
15312 }
15313}
15314
15315fn collect_workspace_entries(
15316 root: &Path,
15317 deadline: Instant,
15318 entry_cap: usize,
15319) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15320 let mut entries = Vec::new();
15321 let mut truncated = false;
15322 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15323 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15324 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15328 (entries, truncated, budget)
15329}
15330
15331fn workspace_inspection_truncation(
15335 walk_budget: &WorkspaceWalkBudget,
15336 git_time_budget_exceeded: bool,
15337 elapsed: Duration,
15338) -> Option<WorkspaceInspectionTruncationV1> {
15339 if !walk_budget.time_budget_exceeded
15340 && !walk_budget.entry_cap_exceeded
15341 && !git_time_budget_exceeded
15342 {
15343 return None;
15344 }
15345 Some(WorkspaceInspectionTruncationV1 {
15346 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15347 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15348 git_time_budget_exceeded,
15349 entries_visited: walk_budget.entries_visited,
15350 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15351 })
15352}
15353
15354fn walk_workspace_directory(
15355 directory: &Path,
15356 relative_directory: Option<&RepositoryPath>,
15357 depth: usize,
15358 entries: &mut Vec<WorkspaceEntry>,
15359 truncated: &mut bool,
15360 budget: &mut WorkspaceWalkBudget,
15361) {
15362 if budget.is_exhausted() {
15363 *truncated = true;
15364 return;
15365 }
15366 let read_dir = match std::fs::read_dir(directory) {
15367 Ok(read_dir) => read_dir,
15368 Err(_) => {
15369 *truncated = true;
15370 return;
15371 }
15372 };
15373 let mut children = Vec::new();
15374 for child in read_dir {
15375 match child {
15376 Ok(child) => children.push(child),
15377 Err(_) => *truncated = true,
15378 }
15379 }
15380 children.sort_by_key(|child| child.file_name());
15381 for child in children {
15382 if budget.record_visit() {
15383 *truncated = true;
15384 return;
15385 }
15386 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15387 *truncated = true;
15388 return;
15389 }
15390 let file_type = match child.file_type() {
15391 Ok(file_type) => file_type,
15392 Err(_) => {
15393 *truncated = true;
15394 continue;
15395 }
15396 };
15397 if file_type.is_symlink() {
15398 continue;
15399 }
15400 let name = match child.file_name().into_string() {
15401 Ok(name) => name,
15402 Err(_) => {
15403 *truncated = true;
15404 continue;
15405 }
15406 };
15407 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15408 continue;
15409 }
15410 let relative_path = match relative_directory {
15411 None => windows_repository_path(name),
15412 Some(parent) => parent
15413 .as_utf8()
15414 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15415 };
15416 let Some(relative_path) = relative_path else {
15417 *truncated = true;
15418 continue;
15419 };
15420 let kind = if file_type.is_dir() {
15421 WorkspaceEntryKind::Directory
15422 } else if file_type.is_file() {
15423 WorkspaceEntryKind::File
15424 } else {
15425 continue;
15426 };
15427 entries.push(WorkspaceEntry {
15428 relative_path: relative_path.clone(),
15429 kind,
15430 });
15431 if file_type.is_dir() {
15432 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15433 walk_workspace_directory(
15434 &child.path(),
15435 Some(&relative_path),
15436 depth + 1,
15437 entries,
15438 truncated,
15439 budget,
15440 );
15441 } else {
15442 *truncated = true;
15443 }
15444 }
15445 }
15446}
15447
15448fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15449 if value.contains('\\') {
15450 return None;
15451 }
15452 RepositoryPath::utf8(value).ok()
15453}
15454
15455fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15456 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15457}
15458
15459fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15468 if name.eq_ignore_ascii_case(".git")
15469 || name.eq_ignore_ascii_case("target")
15470 || name.eq_ignore_ascii_case("node_modules")
15471 {
15472 return true;
15473 }
15474 path.join("CACHEDIR.TAG").is_file()
15475}
15476
15477fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15478 matches!(
15479 status,
15480 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15481 )
15482}
15483
15484#[derive(Clone, Debug, Eq, PartialEq)]
15485enum ContextPackRepositoryHead {
15486 NotRepository,
15487 Unborn,
15488 Commit(String),
15489}
15490
15491#[derive(Clone, Debug, Eq, PartialEq)]
15492struct ContextPackRepositoryObservation {
15493 head: ContextPackRepositoryHead,
15494 git: GitSnapshot,
15495 files: Vec<ContextPackRepositoryFileSource>,
15496}
15497
15498fn stable_context_pack_repository(
15499 first: ContextPackRepositoryObservation,
15500 second: ContextPackRepositoryObservation,
15501) -> Result<ContextPackRepository, NodeFailure> {
15502 if first != second {
15503 return Err(failure(
15504 NodeFailureCode::ContextPackMaterializationFailed,
15505 "context repository changed during bounded capture",
15506 ));
15507 }
15508 Ok(ContextPackRepository::from_sources(
15509 &second.git,
15510 second.files,
15511 ))
15512}
15513
15514fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15521 if Instant::now() < deadline {
15522 return false;
15523 }
15524 append_git_diagnostic(
15525 snapshot,
15526 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15527 );
15528 true
15529}
15530
15531async fn run_git_bounded_with_deadline(
15540 root: &str,
15541 arguments: &[&str],
15542 output_limit: usize,
15543 deadline: Instant,
15544) -> io::Result<GitCommandOutput> {
15545 let remaining = deadline.checked_duration_since(Instant::now());
15546 let timeout_ms = remaining
15547 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15548 .unwrap_or(0)
15549 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15550 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15551}
15552
15553async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15560 let mut snapshot = GitSnapshot {
15561 is_repository: false,
15562 branch: None,
15563 status: Vec::new(),
15564 recent_commits: Vec::new(),
15565 worktrees: Vec::new(),
15566 managed_worktree: None,
15567 truncated: false,
15568 diagnostic: None,
15569 };
15570 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15571 return (snapshot, true);
15572 }
15573 let repository = match run_git_bounded_with_deadline(
15574 root,
15575 &["rev-parse", "--is-inside-work-tree"],
15576 4 * 1_024,
15577 deadline,
15578 )
15579 .await
15580 {
15581 Ok(output) => output,
15582 Err(error) => {
15583 append_git_diagnostic(
15584 &mut snapshot,
15585 &format!("git inspection unavailable: {error}"),
15586 );
15587 return (snapshot, false);
15588 }
15589 };
15590 snapshot.truncated |= repository.truncated;
15591 if repository.timed_out {
15592 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15593 return (snapshot, false);
15594 }
15595 if !repository.success
15596 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15597 {
15598 let stderr = String::from_utf8_lossy(&repository.stderr);
15599 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15600 append_git_diagnostic(&mut snapshot, stderr.trim());
15601 }
15602 return (snapshot, false);
15603 }
15604 snapshot.is_repository = true;
15605
15606 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15607 return (snapshot, true);
15608 }
15609 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15610 Ok(output) => {
15611 snapshot.truncated |= output.truncated;
15612 if output.timed_out {
15613 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15614 } else if output.success {
15615 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15616 if !branch.is_empty() {
15617 snapshot.branch = Some(truncate_git_field(
15618 &branch,
15619 MAX_REPOSITORY_PATH_BYTES,
15620 &mut snapshot.truncated,
15621 ));
15622 }
15623 }
15624 }
15625 Err(error) => append_git_diagnostic(
15626 &mut snapshot,
15627 &format!("git branch query failed: {error}"),
15628 ),
15629 }
15630 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15631 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15632 snapshot.truncated |= output.truncated;
15633 if output.success && !output.timed_out {
15634 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15635 if !head.is_empty() {
15636 snapshot.branch = Some(format!("detached:{head}"));
15637 }
15638 }
15639 }
15640 }
15641
15642 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15643 return (snapshot, true);
15644 }
15645 match run_git_bounded_with_deadline(
15646 root,
15647 &[
15648 "status",
15649 "--porcelain=v1",
15650 "-z",
15651 "--renames",
15652 "--untracked-files=normal",
15653 "--",
15654 ".",
15655 ],
15656 GIT_OUTPUT_MAX_BYTES,
15657 deadline,
15658 )
15659 .await
15660 {
15661 Ok(output) => {
15662 snapshot.truncated |= output.truncated;
15663 if output.timed_out {
15664 append_git_diagnostic(&mut snapshot, "git status timed out");
15665 } else if output.success {
15666 parse_git_status(&output.stdout, &mut snapshot);
15667 } else {
15668 append_git_diagnostic(
15669 &mut snapshot,
15670 String::from_utf8_lossy(&output.stderr).trim(),
15671 );
15672 }
15673 }
15674 Err(error) => append_git_diagnostic(
15675 &mut snapshot,
15676 &format!("git status failed: {error}"),
15677 ),
15678 }
15679
15680 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15681 return (snapshot, true);
15682 }
15683 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15684 match run_git_bounded_with_deadline(
15685 root,
15686 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15687 16 * 1_024,
15688 deadline,
15689 )
15690 .await
15691 {
15692 Ok(output) => {
15693 snapshot.truncated |= output.truncated;
15694 if output.timed_out {
15695 append_git_diagnostic(&mut snapshot, "git log timed out");
15696 } else if output.success {
15697 parse_git_commits(&output.stdout, &mut snapshot);
15698 } else {
15699 let stderr = String::from_utf8_lossy(&output.stderr);
15700 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15701 append_git_diagnostic(&mut snapshot, stderr.trim());
15702 }
15703 }
15704 }
15705 Err(error) => append_git_diagnostic(
15706 &mut snapshot,
15707 &format!("git log failed: {error}"),
15708 ),
15709 }
15710
15711 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15712 return (snapshot, true);
15713 }
15714 match list_git_worktrees_with_deadline(root, deadline).await {
15715 Ok(worktrees) => {
15716 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15717 }
15718 Err(error) => append_git_diagnostic(
15719 &mut snapshot,
15720 &format!("git worktree list failed: {}", error.message),
15721 ),
15722 }
15723 (snapshot, false)
15724}
15725
15726fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15727 let mut remaining = output;
15728 while !remaining.is_empty() {
15729 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15730 snapshot.truncated = true;
15731 break;
15732 }
15733 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15734 snapshot.truncated = true;
15735 break;
15736 };
15737 let record = &remaining[..record_end];
15738 remaining = &remaining[record_end + 1..];
15739 let has_previous_path = record
15740 .get(..2)
15741 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15742 let previous_path_bytes = if has_previous_path {
15743 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15744 snapshot.truncated = true;
15745 break;
15746 };
15747 let previous = &remaining[..previous_end];
15748 remaining = &remaining[previous_end + 1..];
15749 Some(previous)
15750 } else {
15751 None
15752 };
15753 if record.len() < 4
15754 || record[2] != b' '
15755 || !is_git_status_code(record[0])
15756 || !is_git_status_code(record[1])
15757 {
15758 snapshot.truncated = true;
15759 continue;
15760 }
15761 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15762 snapshot.truncated = true;
15763 continue;
15764 };
15765 let previous_path = match previous_path_bytes {
15766 Some(previous) => {
15767 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15768 snapshot.truncated = true;
15769 continue;
15770 };
15771 Some(previous)
15772 }
15773 None => None,
15774 };
15775 snapshot.status.push(GitStatusEntry {
15776 index_status: (record[0] as char).to_string(),
15777 worktree_status: (record[1] as char).to_string(),
15778 path,
15779 previous_path,
15780 });
15781 }
15782}
15783
15784fn is_git_status_code(byte: u8) -> bool {
15785 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15786}
15787
15788fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15789 for line in String::from_utf8_lossy(output).lines() {
15790 let Some((id, summary)) = line.split_once('\u{1f}') else {
15791 continue;
15792 };
15793 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15794 snapshot.truncated = true;
15795 break;
15796 }
15797 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15798 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15799 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15800 }
15801}
15802
15803fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15804 if value.len() <= max_bytes {
15805 return value.to_owned();
15806 }
15807 *truncated = true;
15808 let mut boundary = max_bytes;
15809 while boundary > 0 && !value.is_char_boundary(boundary) {
15810 boundary -= 1;
15811 }
15812 value[..boundary].to_owned()
15813}
15814
15815fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15816 if message.is_empty() {
15817 return;
15818 }
15819 let normalized = message
15820 .chars()
15821 .map(|character| if character.is_control() { ' ' } else { character })
15822 .collect::<String>();
15823 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15824 if !diagnostic.is_empty() {
15825 diagnostic.push_str("; ");
15826 }
15827 diagnostic.push_str(normalized.trim());
15828 snapshot.diagnostic = Some(truncate_git_field(
15829 &diagnostic,
15830 GIT_DIAGNOSTIC_MAX_BYTES,
15831 &mut snapshot.truncated,
15832 ));
15833}
15834
15835async fn run_git_bounded(
15836 root: &str,
15837 arguments: &[&str],
15838 output_limit: usize,
15839) -> io::Result<GitCommandOutput> {
15840 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15841}
15842
15843async fn read_git_history_bounded(
15844 root: &str,
15845 path: Option<&RepositoryPath>,
15846 before: Option<&GitObjectId>,
15847 limit: u16,
15848) -> Result<GitHistoryPage, NodeFailure> {
15849 let path = path
15850 .map(|path| {
15851 path.as_utf8().ok_or_else(|| {
15852 failure(
15853 NodeFailureCode::InvalidRepositoryPath,
15854 "git history path must use UTF-8",
15855 )
15856 })
15857 })
15858 .transpose()?
15859 .unwrap_or(".");
15860 let count = (usize::from(limit) + 1).to_string();
15861 let start = before
15862 .map(|revision| format!("{}^", revision.as_str()))
15863 .unwrap_or_else(|| "HEAD".to_owned());
15864 let arguments = [
15865 "--no-pager",
15866 "log",
15867 "--no-decorate",
15868 "--date=iso-strict",
15869 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15870 "-n",
15871 count.as_str(),
15872 start.as_str(),
15873 "--",
15874 path,
15875 ];
15876 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15877 .await
15878 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15879 if output.timed_out {
15880 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15881 }
15882 if !output.success {
15883 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15884 }
15885 let mut commits = parse_git_history(&output.stdout);
15886 let has_more = commits.len() > usize::from(limit) || output.truncated;
15887 commits.truncate(usize::from(limit));
15888 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15889 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15890}
15891
15892fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15893 String::from_utf8_lossy(output)
15894 .split('\u{1e}')
15895 .filter_map(|record| {
15896 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15897 if fields.len() != 9 {
15898 return None;
15899 }
15900 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
15901 let parents = if fields[1].is_empty() {
15902 Vec::new()
15903 } else {
15904 fields[1]
15905 .split(' ')
15906 .map(|value| GitObjectId::new(value.to_owned()))
15907 .collect::<Result<Vec<_>, _>>()
15908 .ok()?
15909 };
15910 Some(GitCommitDetails {
15911 id,
15912 parents,
15913 subject: bounded_git_text(fields[2], 512),
15914 author_name: bounded_git_text(fields[3], 256),
15915 author_email: bounded_git_text(fields[4], 320),
15916 authored_at: bounded_git_text(fields[5], 64),
15917 committer_name: bounded_git_text(fields[6], 256),
15918 committer_email: bounded_git_text(fields[7], 320),
15919 committed_at: bounded_git_text(fields[8], 64),
15920 signature_status: GitSignatureStatus::NoSignature,
15921 signer: None,
15922 })
15923 })
15924 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
15925 .collect()
15926}
15927
15928fn bounded_git_text(value: &str, max_bytes: usize) -> String {
15929 let normalized = value
15930 .chars()
15931 .map(|character| if character.is_control() { ' ' } else { character })
15932 .collect::<String>();
15933 if normalized.len() <= max_bytes {
15934 return normalized;
15935 }
15936 let mut boundary = max_bytes;
15937 while boundary > 0 && !normalized.is_char_boundary(boundary) {
15938 boundary -= 1;
15939 }
15940 normalized[..boundary].to_owned()
15941}
15942
15943async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
15944 let path = request.path.as_ref().map(|path| {
15945 path.as_utf8()
15946 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
15947 }).transpose()?;
15948 let mut arguments = match &request.mode {
15949 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
15950 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
15951 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
15952 };
15953 if let GitDiffMode::Commit { revision } = &request.mode {
15954 arguments.push(revision.as_str());
15955 }
15956 arguments.push("--");
15957 if let Some(path) = path {
15958 arguments.push(path);
15959 }
15960 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
15961 .await
15962 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
15963 if output.timed_out {
15964 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
15965 }
15966 if !output.success {
15967 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
15968 }
15969 Ok(GitDiff {
15970 mode: request.mode,
15971 path: request.path,
15972 text: String::from_utf8_lossy(&output.stdout).into_owned(),
15973 truncated: output.truncated,
15974 })
15975}
15976
15977async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
15978 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
15979 ResponseEnvelope { request_id: envelope.request_id, result }
15980}
15981
15982async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
15983 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
15984 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
15985 }
15986 let read_only = matches!(
15987 &request,
15988 NodeRequest::Snapshot
15989 | NodeRequest::Resync { .. }
15990 | NodeRequest::BrowseHostDirectories { .. }
15991 | NodeRequest::InspectWorkspace { .. }
15992 | NodeRequest::ReadWorkspaceFile { .. }
15993 | NodeRequest::ReadGitHistory { .. }
15994 | NodeRequest::ReadGitDiff { .. }
15995 | NodeRequest::CatalogNativeSessions { .. }
15996 | NodeRequest::PageNativeSessions { .. }
15997 | NodeRequest::PreviewNativeSession { .. }
15998 | NodeRequest::PreviewSessionRecord { .. }
15999 );
16000 let _mutation_guard = if read_only {
16001 None
16002 } else {
16003 let guard = shared.mutation_gate.lock().await;
16004 if shared.shutdown.load(Ordering::Acquire) {
16005 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16006 }
16007 Some(guard)
16008 };
16009 match request {
16010 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16011 event_sequence: shared.current_sequence(),
16012 controller: shared.controller_state(),
16013 snapshot: shared.snapshot(),
16014 }),
16015 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16016 NodeRequest::ArmHarnessMcpReservation {
16017 reservation_id,
16018 activation_digest,
16019 spawn_spec,
16020 launch,
16021 expires_at_unix_ms,
16022 } => {
16023 shared.require_controller(connection_id, role)?;
16024 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16025 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16026 })?;
16027 let expires_at_unix_ms = registry
16028 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16029 .await
16030 .map_err(harness_mcp_failure)?;
16031 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16032 }
16033 NodeRequest::SpawnSpecWithHarnessMcp {
16034 reservation_id,
16035 activation_digest,
16036 spec,
16037 deadline_unix_ms,
16038 } => {
16039 shared.require_controller(connection_id, role)?;
16040 let receipt = shared
16041 .spawn_from_spec_with_harness_mcp(
16042 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16043 )
16044 .await?;
16045 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16046 }
16047 NodeRequest::ActivateHarnessMcpReservation {
16048 reservation_id,
16049 activation_digest,
16050 record_id,
16051 session,
16052 } => {
16053 shared.require_controller(connection_id, role)?;
16054 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16055 current.instance_id == session.session.instance_id
16056 && current.generation == session.session.generation
16057 }).and_then(|current| current.process_id).ok_or_else(|| {
16058 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16059 })?;
16060 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16061 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16062 })?.activate(
16063 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16064 ).map_err(harness_mcp_failure)?;
16065 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16066 }
16067 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16068 shared.require_controller(connection_id, role)?;
16069 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16070 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16071 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16072 if let Some(instance_id) = instance_id {
16073 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16074 }
16075 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16076 }
16077 NodeRequest::PutHarnessMcpReplyChunk {
16078 reservation_id, activation_digest, record_id, session, call_id,
16079 offset, final_chunk, chunk_hex,
16080 } => {
16081 shared.require_controller(connection_id, role)?;
16082 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16083 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16084 })?.put_reply_chunk(
16085 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16086 offset, final_chunk, &chunk_hex,
16087 ).map_err(harness_mcp_failure)?;
16088 Ok(NodeResponse::ReplyChunkAccepted {
16089 reservation_id, activation_digest, record_id, session, call_id,
16090 next_offset, completed,
16091 })
16092 }
16093 NodeRequest::RejectHarnessMcpCall {
16094 reservation_id, activation_digest, record_id, session, call_id, reason,
16095 } => {
16096 shared.require_controller(connection_id, role)?;
16097 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16098 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16099 })?.reject_call(
16100 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16101 ).map_err(harness_mcp_failure)?;
16102 Ok(NodeResponse::CallRejected {
16103 reservation_id, activation_digest, record_id, session, call_id,
16104 })
16105 }
16106 NodeRequest::BeginDeliveryStage { manifest } => {
16107 shared.require_controller(connection_id, role)?;
16108 shared.begin_delivery_stage(manifest)
16109 }
16110 NodeRequest::PutDeliveryBlobChunk {
16111 stage_id,
16112 blob_digest,
16113 offset,
16114 chunk_hex,
16115 } => {
16116 shared.require_controller(connection_id, role)?;
16117 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16118 }
16119 NodeRequest::CommitDeliveryStage { stage_id } => {
16120 shared.require_controller(connection_id, role)?;
16121 let receipt = shared.commit_delivery_stage(stage_id)?;
16122 Ok(NodeResponse::DeliveryCommitted { receipt })
16123 }
16124 NodeRequest::AbortDeliveryStage { stage_id } => {
16125 shared.require_controller(connection_id, role)?;
16126 shared.abort_delivery_stage(&stage_id)?;
16127 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16128 }
16129 NodeRequest::BrowseHostDirectories { directory, after } => {
16130 let listing = shared.browse_host_directories(directory, after).await?;
16131 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16132 }
16133 NodeRequest::InspectWorkspace { workspace_id } => {
16134 let inspection = shared.inspect_workspace(workspace_id).await?;
16135 Ok(NodeResponse::WorkspaceInspected { inspection })
16136 }
16137 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16138 let file = shared.read_workspace_file(workspace_id, path).await?;
16139 Ok(NodeResponse::WorkspaceFileRead { file })
16140 }
16141 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16142 shared.require_controller(connection_id, role)?;
16143 let file = shared
16144 .write_workspace_file(workspace_id, path, expected_revision, text)
16145 .await?;
16146 Ok(NodeResponse::WorkspaceFileWritten { file })
16147 }
16148 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16149 shared.require_controller(connection_id, role)?;
16150 let file = shared.create_workspace_file(workspace_id, path).await?;
16151 Ok(NodeResponse::WorkspaceFileCreated { file })
16152 }
16153 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16154 shared.require_controller(connection_id, role)?;
16155 let entry = shared
16156 .create_workspace_directory(workspace_id.clone(), path)
16157 .await?;
16158 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16159 }
16160 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16161 if role != ClientRole::Operator {
16162 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16163 }
16164 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16165 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16166 }
16167 let page = shared
16168 .read_git_history(workspace_id.clone(), path, before, limit)
16169 .await?;
16170 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16171 }
16172 NodeRequest::ReadGitDiff { workspace_id, request } => {
16173 if role != ClientRole::Operator {
16174 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16175 }
16176 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16177 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16178 }
16179 NodeRequest::CatalogNativeSessions { route, limit } => {
16180 if role != ClientRole::Operator {
16181 return Err(failure(
16182 NodeFailureCode::ObserverReadOnly,
16183 "native session catalog requires an operator connection",
16184 ));
16185 }
16186 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16187 return Err(failure(
16188 NodeFailureCode::InvalidRequest,
16189 "native session catalog limit is invalid",
16190 ));
16191 }
16192 let (entries, summary) = shared
16193 .catalog_native_sessions(route.clone(), limit)
16194 .await?;
16195 Ok(NodeResponse::NativeSessionsCataloged {
16196 route,
16197 entries,
16198 summary: Some(summary),
16199 })
16200 }
16201 NodeRequest::PageNativeSessions {
16202 route,
16203 window,
16204 catalog_revision,
16205 recent_cutoff_unix_ms,
16206 after_selection_id,
16207 limit,
16208 } => {
16209 if role != ClientRole::Operator {
16210 return Err(failure(
16211 NodeFailureCode::ObserverReadOnly,
16212 "native session catalog paging requires an operator connection",
16213 ));
16214 }
16215 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16216 || after_selection_id
16217 .as_deref()
16218 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16219 {
16220 return Err(failure(
16221 NodeFailureCode::InvalidRequest,
16222 "native session catalog page request is invalid",
16223 ));
16224 }
16225 let page = shared
16226 .page_native_sessions(
16227 route.clone(),
16228 window,
16229 catalog_revision,
16230 recent_cutoff_unix_ms,
16231 after_selection_id,
16232 limit,
16233 )
16234 .await?;
16235 Ok(NodeResponse::NativeSessionsPaged {
16236 route,
16237 page,
16238 })
16239 }
16240 NodeRequest::PreviewNativeSession {
16241 selection,
16242 message_limit,
16243 } => {
16244 if role != ClientRole::Operator {
16245 return Err(failure(
16246 NodeFailureCode::ObserverReadOnly,
16247 "native session preview requires an operator connection",
16248 ));
16249 }
16250 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16251 .contains(&message_limit)
16252 || selection.validate().is_err()
16253 {
16254 return Err(failure(
16255 NodeFailureCode::InvalidRequest,
16256 "native session preview request is invalid",
16257 ));
16258 }
16259 let preview = shared
16260 .preview_native_session(selection.clone(), message_limit)
16261 .await?;
16262 Ok(NodeResponse::NativeSessionPreviewed {
16263 selection,
16264 preview,
16265 })
16266 }
16267 NodeRequest::IndexNativeSession {
16268 selection,
16269 display_name,
16270 } => {
16271 shared.require_controller(connection_id, role)?;
16272 let record = shared
16273 .index_native_session(selection.clone(), display_name)
16274 .await?;
16275 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16276 }
16277 NodeRequest::PreviewSessionRecord {
16278 record_id,
16279 message_limit,
16280 } => {
16281 if role != ClientRole::Operator {
16282 return Err(failure(
16283 NodeFailureCode::ObserverReadOnly,
16284 "native session preview requires an operator connection",
16285 ));
16286 }
16287 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16288 .contains(&message_limit)
16289 {
16290 return Err(failure(
16291 NodeFailureCode::InvalidRequest,
16292 "native session preview request is invalid",
16293 ));
16294 }
16295 let record = shared.record(&record_id)?;
16296 let working_directory = shared.workspace_root(&record.workspace_id)?;
16297 if !platform::roots_equal(
16298 &working_directory,
16299 windows_path_text(&record.canonical_root),
16300 ) {
16301 return Err(failure(
16302 NodeFailureCode::SessionRecordConflict,
16303 "managed session workspace root changed; refusing to preview another directory",
16304 ));
16305 }
16306 let identity = record.provider_session.clone().ok_or_else(|| {
16307 failure(
16308 NodeFailureCode::BackendOperationFailed,
16309 "native session preview is unavailable for this session record",
16310 )
16311 })?;
16312 if identity.key != ProviderSessionKey::SessionId {
16313 return Err(failure(
16314 NodeFailureCode::BackendOperationFailed,
16315 "native session preview is unavailable for this session identity",
16316 ));
16317 }
16318 let preview = shared
16319 .preview_native_session_by_session_id(
16320 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16321 )
16322 .await?;
16323 if preview.session_id != identity.id {
16324 return Err(failure(
16325 NodeFailureCode::SessionRecordConflict,
16326 "native session preview identity changed while loading",
16327 ));
16328 }
16329 shared.revalidate_session_record_preview(&record, &identity)?;
16330 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16331 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16332 record_id: record_id.clone(),
16333 summary: SessionHistorySummaryV1::from(&preview),
16334 });
16335 Ok(NodeResponse::SessionRecordPreviewed {
16336 record_id,
16337 preview,
16338 })
16339 }
16340 NodeRequest::AcquireController { lease_ms } => {
16341 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16342 Ok(NodeResponse::Controller { controller: Some(controller) })
16343 }
16344 NodeRequest::ReleaseController => {
16345 shared.release_controller(connection_id);
16346 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16347 }
16348 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16349 shared.require_controller(connection_id, role)?;
16350 let root = require_windows_path(root)?;
16351 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16352 let workspace = shared.register_workspace(workspace_id, root).await?;
16353 Ok(NodeResponse::WorkspaceRegistered { workspace })
16354 }
16355 NodeRequest::CreateStandaloneWorkspace {
16356 workspace_id,
16357 root,
16358 initial_branch,
16359 } => {
16360 shared.require_controller(connection_id, role)?;
16361 let root = require_windows_path(root)?;
16362 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16363 let workspace = shared
16364 .create_standalone_workspace(workspace_id, root, initial_branch)
16365 .await?;
16366 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16367 }
16368 NodeRequest::UnregisterWorkspace { workspace_id } => {
16369 shared.require_controller(connection_id, role)?;
16370 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16371 shared.unregister_workspace(&workspace_id)?;
16372 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16373 }
16374 NodeRequest::CreateWorktree {
16375 source_workspace_id,
16376 workspace_id,
16377 target_root,
16378 branch,
16379 base,
16380 } => {
16381 shared.require_controller(connection_id, role)?;
16382 let target_root = require_windows_path(target_root)?;
16383 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16384 let (worktree, workspace) = shared
16385 .create_worktree(
16386 source_workspace_id,
16387 workspace_id,
16388 target_root,
16389 branch,
16390 base,
16391 )
16392 .await?;
16393 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16394 }
16395 NodeRequest::RemoveWorktree {
16396 source_workspace_id,
16397 target_root,
16398 } => {
16399 shared.require_controller(connection_id, role)?;
16400 let native_target_root = require_windows_path(target_root.clone())?;
16401 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16402 let workspace_id = shared
16403 .remove_worktree(source_workspace_id, native_target_root)
16404 .await?;
16405 Ok(NodeResponse::WorktreeRemoved {
16406 target_root,
16407 workspace_id,
16408 })
16409 }
16410 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16411 shared.require_controller(connection_id, role)?;
16412 if !terminal_size.is_valid() {
16413 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16414 }
16415 if let Some(prompt) = initial_prompt.as_deref() {
16416 validate_node_text("spawn initial prompt", prompt)?;
16417 }
16418 match shared
16419 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16420 .await
16421 {
16422 Ok(session) => {
16423 shared.mark_spawn_provisional(session.clone());
16424 Ok(NodeResponse::SpawnAccepted { session })
16425 }
16426 Err(err) => {
16427 tracing::warn!(
16428 %provider,
16429 %workspace_id,
16430 ?mode,
16431 code = ?err.code,
16432 message = %err.message,
16433 "session spawn refused"
16434 );
16435 Err(err)
16436 }
16437 }
16438 }
16439 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16440 shared.require_controller(connection_id, role)?;
16441 if !terminal_size.is_valid() {
16442 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16443 }
16444 if let Some(prompt) = initial_prompt.as_deref() {
16445 validate_node_text("spawn initial prompt", prompt)?;
16446 }
16447 let plan = backend_paths::plan_launch_options(
16448 provider.as_str(),
16449 mode,
16450 options.model.is_some(),
16451 options.effort.is_some(),
16452 )
16453 .map_err(|r| {
16454 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16455 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16456 })?;
16457 let selection = spawn_selection(&options)?;
16458 let pty_selection = match plan {
16459 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16460 _ => None,
16461 };
16462 let spawned = SPAWN_SESSION_OPTIONS
16463 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16464 .await;
16465 match spawned {
16466 Ok(session) => {
16467 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16468 shared
16469 .pending_acp_options
16470 .lock()
16471 .unwrap_or_else(|p| p.into_inner())
16472 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16473 }
16474 shared.mark_spawn_provisional(session.clone());
16475 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16476 Ok(NodeResponse::SpawnAccepted { session })
16477 }
16478 Err(err) => {
16479 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16480 Err(err)
16481 }
16482 }
16483 }
16484 NodeRequest::SpawnSpec { spec } => {
16485 shared.require_controller(connection_id, role)?;
16486 let receipt = shared.spawn_from_spec(spec).await?;
16487 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16488 }
16489 NodeRequest::SpawnManagedWorktree { request } => {
16490 shared.require_controller(connection_id, role)?;
16491 let receipt = shared.spawn_managed_worktree(request).await?;
16492 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16493 }
16494 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16495 shared.require_controller(connection_id, role)?;
16496 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16497 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16498 }
16499 NodeRequest::CleanupManagedWorktree { lease_id } => {
16500 shared.require_controller(connection_id, role)?;
16501 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16502 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16503 }
16504 NodeRequest::DiscoverHistory { session, limit } => {
16505 controlled_session(shared, connection_id, role, &session)?;
16506 let candidates = shared.discover_history(&session, limit).await?;
16507 Ok(NodeResponse::HistoryDiscovered {
16508 session,
16509 candidates,
16510 })
16511 }
16512 NodeRequest::LoadHistory {
16513 session,
16514 candidate_id,
16515 } => {
16516 controlled_session(shared, connection_id, role, &session)?;
16517 let loaded = shared
16518 .load_history(&session, candidate_id)
16519 .await?;
16520 Ok(NodeResponse::HistoryLoaded {
16521 session,
16522 session_id: loaded.session_id,
16523 message_count: loaded.message_count,
16524 completed_turn_count: loaded.completed_turn_count,
16525 })
16526 }
16527 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16528 shared.require_controller(connection_id, role)?;
16529 let context = shared
16530 .export_context_pack_for_session_record(&record_id, &session, false)
16531 .await?;
16532 Ok(NodeResponse::ContextPackForSessionRecordExported {
16533 record_id,
16534 session,
16535 context,
16536 })
16537 }
16538 NodeRequest::ExportContextPack { session } => {
16539 controlled_session(shared, connection_id, role, &session)?;
16540 let context = shared.export_context_pack(&session).await?;
16541 Ok(NodeResponse::ContextPackExported { context })
16542 }
16543 NodeRequest::ForgetContextPack { context_id } => {
16544 shared.require_controller(connection_id, role)?;
16545 shared.forget_context_pack(&context_id)?;
16546 Ok(NodeResponse::ContextPackForgotten { context_id })
16547 }
16548 NodeRequest::ResolveDurableContextPack { context_id } => {
16549 let context = shared
16553 .context_catalog
16554 .read()
16555 .unwrap_or_else(|poisoned| poisoned.into_inner())
16556 .get(&context_id)
16557 .map(|pack| pack.receipt().clone())
16558 .ok_or_else(|| {
16559 failure(
16560 NodeFailureCode::UnknownContextPack,
16561 "context pack is not present",
16562 )
16563 })?;
16564 Ok(NodeResponse::DurableContextPackResolved { context })
16565 }
16566 NodeRequest::ReadContextPack { digest } => {
16567 let (id, byte_len, bytes) = shared
16572 .context_catalog
16573 .read()
16574 .unwrap_or_else(|poisoned| poisoned.into_inner())
16575 .get_by_digest(&digest)
16576 .map(|pack| {
16577 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16578 })
16579 .ok_or_else(|| {
16580 failure(
16581 NodeFailureCode::UnknownContextPack,
16582 &format!("context pack {digest} not on this node"),
16583 )
16584 })?;
16585 tracing::debug!(
16586 digest = digest.as_str(),
16587 byte_len,
16588 "read context pack bytes",
16589 );
16590 Ok(NodeResponse::ContextPackBytesRead {
16591 pack: ContextPackBytesRead {
16592 digest,
16593 id,
16594 byte_len,
16595 bytes,
16596 },
16597 })
16598 }
16599 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16600 let provider = controlled_session(shared, connection_id, role, &session)?;
16601 if let Some(prompt) = initial_prompt.as_deref() {
16602 validate_node_text("resume initial prompt", prompt)?;
16603 }
16604 let runtime_requirement = if initial_prompt.is_some() {
16605 ProviderRuntimeRequirement::ResumeWithPrompt
16606 } else {
16607 ProviderRuntimeRequirement::Resume
16608 };
16609 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16610 let runtime_policy = shared
16611 .admit_provider_runtime(&provider, runtime_requirement)
16612 .await?;
16613 let working_directory = shared.bound_workspace_root(&session)?;
16614 let request = ResumeLaunchRequest {
16615 working_directory,
16616 terminal_size,
16617 initial_prompt,
16618 };
16619 request
16620 .validate()
16621 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16622 let command = shared.prepare_command(ControlCommand::Resume {
16623 instance_id: session.session.instance_id,
16624 target: ResumeTarget::CurrentProvider,
16625 runtime_policy,
16626 request,
16627 });
16628 shared.arm_resume(&session, command.id, runtime_policy)?;
16629 let dispatch = shared.dispatch_envelope(command);
16630 if let Err(error) = dispatch {
16631 shared.clear_armed_resume(&session);
16632 return Err(error);
16633 }
16634 Ok(NodeResponse::Accepted)
16635 }
16636 NodeRequest::RenameSessionRecord {
16637 record_id,
16638 display_name,
16639 } => {
16640 shared.require_controller(connection_id, role)?;
16641 let record = shared.rename_session_record(&record_id, display_name)?;
16642 Ok(NodeResponse::SessionRecordUpdated { record })
16643 }
16644 NodeRequest::SetSessionTask {
16645 record_id,
16646 expected_revision,
16647 target,
16648 } => {
16649 shared.require_controller(connection_id, role)?;
16650 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16651 Ok(NodeResponse::SessionRecordUpdated { record })
16652 }
16653 NodeRequest::IndexProviderSession {
16654 workspace_id,
16655 provider,
16656 identity,
16657 display_name,
16658 } => {
16659 shared.require_controller(connection_id, role)?;
16660 let record = shared.index_provider_session(
16661 workspace_id,
16662 provider,
16663 identity,
16664 display_name,
16665 )?;
16666 Ok(NodeResponse::ProviderSessionIndexed { record })
16667 }
16668 NodeRequest::ResumeSessionRecord {
16669 record_id,
16670 terminal_size,
16671 initial_prompt,
16672 } => {
16673 shared.require_controller(connection_id, role)?;
16674 if let Some(prompt) = initial_prompt.as_deref() {
16675 validate_node_text("managed resume initial prompt", prompt)?;
16676 }
16677 let (record, session) = shared
16678 .resume_session_record(
16679 &record_id,
16680 terminal_size,
16681 initial_prompt,
16682 )
16683 .await?;
16684 Ok(NodeResponse::SessionRecordResumed { record, session })
16685 }
16686 NodeRequest::ForgetSessionRecord { record_id } => {
16687 shared.require_controller(connection_id, role)?;
16688 shared.forget_session_record(&record_id).await?;
16689 Ok(NodeResponse::SessionRecordForgotten { record_id })
16690 }
16691 NodeRequest::Prompt { session, text } => {
16692 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16693 validate_node_text("prompt", &text)?;
16694 shared.require_session_runtime_policy(
16695 &session,
16696 ProviderRuntimeRequirement::SemanticPrompt,
16697 )?;
16698 let framing = prompt_framing(&agent_id);
16699 shared
16700 .dispatch_input_bounded(
16701 &session,
16702 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16703 )
16704 .await?;
16705 Ok(NodeResponse::Accepted)
16706 }
16707 NodeRequest::Paste { session, text } => {
16708 controlled_session(shared, connection_id, role, &session)?;
16709 validate_node_text("paste", &text)?;
16710 shared.require_session_runtime_policy(
16711 &session,
16712 ProviderRuntimeRequirement::SemanticPrompt,
16713 )?;
16714 shared
16715 .dispatch_input_bounded(
16716 &session,
16717 InputAction::InsertDraft(PromptPayload {
16718 text,
16719 framing: PromptFraming::BracketedPaste,
16720 }),
16721 )
16722 .await?;
16723 Ok(NodeResponse::Accepted)
16724 }
16725 NodeRequest::Input { session, text } => {
16726 controlled_session(shared, connection_id, role, &session)?;
16727 validate_node_text("terminal input", &text)?;
16728 shared
16729 .dispatch_input_bounded(
16730 &session,
16731 InputAction::TerminalText(TerminalText { text }),
16732 )
16733 .await?;
16734 Ok(NodeResponse::Accepted)
16735 }
16736 NodeRequest::TerminalBytes { session, bytes } => {
16737 controlled_session(shared, connection_id, role, &session)?;
16738 shared.sessionize_spawn(session.session.instance_id);
16739 let action = terminal_bytes_action(bytes)?;
16740 shared
16741 .dispatch_input_bounded(&session, action)
16742 .await?;
16743 Ok(NodeResponse::Accepted)
16744 }
16745 NodeRequest::TerminalControl { session, control } => {
16746 controlled_session(shared, connection_id, role, &session)?;
16747 shared
16748 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16749 .await?;
16750 Ok(NodeResponse::Accepted)
16751 }
16752 NodeRequest::Resize { session, size } => {
16753 controlled_session(shared, connection_id, role, &session)?;
16754 shared.sessionize_spawn(session.session.instance_id);
16755 if !size.is_valid() {
16756 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16757 }
16758 shared.dispatch_resize_bounded(&session, size).await?;
16759 Ok(NodeResponse::Accepted)
16760 }
16761 NodeRequest::Interrupt { session } => {
16762 controlled_session(shared, connection_id, role, &session)?;
16763 shared
16764 .dispatch_input_bounded(
16765 &session,
16766 InputAction::TerminalControl(TerminalControl::Interrupt),
16767 )
16768 .await?;
16769 Ok(NodeResponse::Accepted)
16770 }
16771 NodeRequest::Stop { session, force } => {
16772 controlled_session(shared, connection_id, role, &session)?;
16773 shared.sessionize_spawn(session.session.instance_id);
16774 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16775 Ok(NodeResponse::Accepted)
16776 }
16777 NodeRequest::Remove { session } => {
16778 controlled_session(shared, connection_id, role, &session)?;
16779 shared.remove_session(&session).await?;
16780 Ok(NodeResponse::Accepted)
16781 }
16782 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16783 shared.require_controller(connection_id, role)?;
16784 let snapshot = shared.require_acp_session(&session)?;
16785 let interaction = snapshot
16786 .provider
16787 .interactions
16788 .iter()
16789 .find(|interaction| {
16790 interaction_correlation(
16791 session.session.instance_id,
16792 session.session.generation,
16793 interaction.id.0,
16794 ) == correlation_id
16795 })
16796 .ok_or_else(|| failure(
16797 NodeFailureCode::InvalidRequest,
16798 "unknown interaction correlation",
16799 ))?;
16800 if interaction.status != ProviderInteractionStatus::Pending {
16801 return Err(failure(
16802 NodeFailureCode::BackendOperationFailed,
16803 "interaction has already been resolved",
16804 ));
16805 }
16806 response
16807 .validate_for(interaction.interaction_kind)
16808 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16809 let interaction_id = interaction.id;
16810 let response_kind = response.kind();
16811 let response_outcome = response.outcome();
16812 shared.dispatch(ControlCommand::ResolveInteraction {
16813 instance_id: session.session.instance_id,
16814 generation: session.session.generation,
16815 interaction_id,
16816 response,
16817 })?;
16818 tracing::info!(
16824 node_id = %shared.node_id,
16825 session = ?session.session,
16826 correlation_id = %correlation_id,
16827 interaction_id = interaction_id.0,
16828 response_kind = ?response_kind,
16829 outcome = ?response_outcome,
16830 "interaction resolved",
16831 );
16832 Ok(NodeResponse::Accepted)
16833 }
16834 NodeRequest::SetSessionMode { session, mode_id } => {
16835 shared.require_controller(connection_id, role)?;
16836 shared.require_acp_session(&session)?;
16837 shared.dispatch(ControlCommand::SetSessionMode {
16838 instance_id: session.session.instance_id,
16839 mode_id,
16840 })?;
16841 Ok(NodeResponse::Accepted)
16842 }
16843 NodeRequest::SetSessionConfigOption {
16844 session,
16845 option_id,
16846 value_json,
16847 } => {
16848 shared.require_controller(connection_id, role)?;
16849 shared.require_acp_session(&session)?;
16850 shared.dispatch(ControlCommand::SetSessionConfigOption {
16851 instance_id: session.session.instance_id,
16852 option_id,
16853 value_json,
16854 })?;
16855 Ok(NodeResponse::Accepted)
16856 }
16857 NodeRequest::SetSessionModel { session, model_id } => {
16858 shared.require_controller(connection_id, role)?;
16859 shared.require_acp_session(&session)?;
16860 shared.dispatch(ControlCommand::SetSessionModel {
16861 instance_id: session.session.instance_id,
16862 model_id,
16863 })?;
16864 Ok(NodeResponse::Accepted)
16865 }
16866 NodeRequest::Shutdown => {
16867 shared.require_controller(connection_id, role)?;
16868 shared.begin_shutdown_locked().await?;
16869 Ok(NodeResponse::ShuttingDown)
16870 }
16871 }
16872}
16873
16874fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16875 shared.require_controller(connection_id, role)?;
16876 shared.validate_address(session)
16877}
16878
16879fn select_history_candidate(
16885 candidates: &[HistoryCandidateSummary],
16886 provider_session_id: Option<&str>,
16887) -> Result<String, NodeFailure> {
16888 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16889 Some(id) => candidate.session_id_hint == id,
16890 None => true,
16891 });
16892 let candidate = matches.next().ok_or_else(|| {
16893 failure(
16894 NodeFailureCode::SessionRecordConflict,
16895 if provider_session_id.is_some() {
16896 "no history candidate matches the managed provider session"
16897 } else {
16898 "no history candidate exists for the managed session"
16899 },
16900 )
16901 })?;
16902 if matches.next().is_some() {
16903 return Err(failure(
16904 NodeFailureCode::SessionRecordConflict,
16905 if provider_session_id.is_some() {
16906 "history identity is ambiguous for the managed provider session"
16907 } else {
16908 "history is ambiguous without a verified provider session identity"
16909 },
16910 ));
16911 }
16912 Ok(candidate.id.clone())
16913}
16914
16915fn resolved_spawn_profile_summary(
16916 profile: &SpawnProfileDefaults,
16917 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
16918) -> Option<SpawnProfileSummary> {
16919 let environment_profile = match profile.environment_profile_id.as_ref() {
16920 None => None,
16921 Some(profile_id) => {
16922 let binding = environment_profiles.get(profile_id)?;
16923 if binding.provider != profile.provider
16924 || binding.native_profile_id(profile.mode).is_none()
16925 {
16926 return None;
16927 }
16928 Some(ResolvedEnvironmentProfileReceipt {
16929 profile_id: binding.id.clone(),
16930 profile_revision: binding.revision.clone(),
16931 network_allowlist: None,
16932 browser_profile_id: None,
16933 })
16934 }
16935 };
16936 Some(SpawnProfileSummary {
16937 id: profile.profile_id.clone(),
16938 revision: profile.revision.clone(),
16939 environment_profile,
16940 })
16941}
16942
16943fn session_record_export_target_matches(
16962 expected: &ManagedSessionRecord,
16963 current: &ManagedSessionRecord,
16964 session: &SessionAddress,
16965 allow_clean_detachment: bool,
16966) -> bool {
16967 if current == expected && current.active_session.as_ref() == Some(session) {
16968 return current.state == ManagedSessionState::Live;
16969 }
16970 allow_clean_detachment
16971 && current.state == ManagedSessionState::Dormant
16972 && current.active_session.is_none()
16973 && current.record_id == expected.record_id
16974 && current.display_name == expected.display_name
16975 && current.provider == expected.provider
16976 && current.mode == expected.mode
16977 && current.workspace_id == expected.workspace_id
16978 && current.canonical_root == expected.canonical_root
16979 && current.provider_session == expected.provider_session
16980 && current.environment_profile == expected.environment_profile
16981 && current.bundle == expected.bundle
16982 && current.context_id == expected.context_id
16983 && current.context == expected.context
16984 && current.exported_context == expected.exported_context
16985 && current.task_binding == expected.task_binding
16986 && current.created_at_unix_ms == expected.created_at_unix_ms
16987 && current.last_error == expected.last_error
16988}
16989
16990fn session_record_context_export_binding_is_exact(
16991 expected: &ManagedSessionRecord,
16992 current: &ManagedSessionRecord,
16993 identity: Option<&ProviderSessionIdentity>,
16994 session: &SessionAddress,
16995 binding: &SessionBinding,
16996 provider: &AgentId,
16997 current_root: &str,
16998 allow_clean_detachment: bool,
16999) -> bool {
17000 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17001 && current.provider_session.as_ref() == identity
17002 && current.workspace_id == session.workspace_id
17003 && provider == ¤t.provider
17004 && binding.workspace_id == session.workspace_id
17005 && binding.generation == session.session.generation
17006 && binding.record_id.as_ref() == Some(¤t.record_id)
17007 && platform::roots_equal(
17008 current_root,
17009 windows_path_text(¤t.canonical_root),
17010 )
17011}
17012
17013fn session_record_context_export_source_is_usable(
17014 record: &ManagedSessionRecord,
17015 status: &SessionStatus,
17016 allow_clean_detachment: bool,
17017) -> bool {
17018 (record.state == ManagedSessionState::Live
17019 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17020 && context_pack_source_status_is_usable(status)
17021}
17022
17023fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17024 PromptFraming::BracketedPaste
17025}
17026
17027fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17028 if text.len() > MAX_NODE_TEXT_BYTES {
17029 return Err(failure(
17030 NodeFailureCode::InvalidRequest,
17031 &format!(
17032 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17033 text.len(),
17034 ),
17035 ));
17036 }
17037 Ok(())
17038}
17039
17040fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17041 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17042 return Err(failure(
17043 NodeFailureCode::InvalidRequest,
17044 &format!(
17045 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17046 bytes.len(),
17047 ),
17048 ));
17049 }
17050 Ok(())
17051}
17052
17053fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17054 validate_terminal_bytes(&bytes)?;
17055 Ok(InputAction::TerminalBytes(bytes))
17056}
17057
17058fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17059 if root.is_empty()
17060 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17061 || root.chars().any(char::is_control)
17062 {
17063 return Err(failure(
17064 NodeFailureCode::InvalidWorkspaceRoot,
17065 &format!(
17066 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17067 ),
17068 ));
17069 }
17070 Ok(())
17071}
17072
17073fn canonical_standalone_workspace_candidate(
17074 workspace_id: &WorkspaceId,
17075 root: &str,
17076) -> Result<String, NodeServerError> {
17077 let path = Path::new(root);
17078 match std::fs::symlink_metadata(path) {
17079 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17080 .map(|workspace| workspace.canonical_root),
17081 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17082 if !path.is_absolute() {
17083 return Err(NodeServerError::InvalidWorkspaceRoot {
17084 workspace_id: workspace_id.clone(),
17085 path: root.to_owned(),
17086 message: "path is not absolute".to_owned(),
17087 });
17088 }
17089 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17090 workspace_id: workspace_id.clone(),
17091 path: root.to_owned(),
17092 message: "path has no standalone directory name".to_owned(),
17093 })?;
17094 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17095 workspace_id: workspace_id.clone(),
17096 path: root.to_owned(),
17097 message: "path has no parent directory".to_owned(),
17098 })?;
17099 let canonical = std::fs::canonicalize(parent)
17100 .map(|parent| parent.join(name))
17101 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17102 workspace_id: workspace_id.clone(),
17103 path: root.to_owned(),
17104 message: error.to_string(),
17105 })?;
17106 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17107 NodeServerError::InvalidWorkspaceRoot {
17108 workspace_id: workspace_id.clone(),
17109 path: path.to_string_lossy().into_owned(),
17110 message: "canonical path is not valid Unicode".to_owned(),
17111 }
17112 })?;
17113 let canonical_root = platform::normalize_canonical_root(canonical_root);
17114 validate_workspace_root(workspace_id, &canonical_root)?;
17115 Ok(canonical_root)
17116 }
17117 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17118 workspace_id: workspace_id.clone(),
17119 path: root.to_owned(),
17120 message: error.to_string(),
17121 }),
17122 }
17123}
17124
17125fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17126 let Some(revision) = revision else {
17127 return Ok(());
17128 };
17129 if revision.is_empty()
17130 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17131 || revision.chars().any(char::is_control)
17132 {
17133 return Err(failure(
17134 NodeFailureCode::InvalidRequest,
17135 &format!(
17136 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17137 ),
17138 ));
17139 }
17140 Ok(())
17141}
17142
17143fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17144 let code = match error.kind {
17145 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17146 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17147 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17148 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17149 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17150 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17151 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17152 };
17153 failure(code, &error.message)
17154}
17155
17156fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17157 let code = match error.kind {
17158 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17159 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17160 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17161 NodeFailureCode::BackendOperationFailed
17162 }
17163 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17164 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17165 }
17166 };
17167 failure(code, error.message)
17168}
17169
17170fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17171 let (code, message) = match error.kind {
17172 GitWorktreeErrorKind::Invalid => (
17173 NodeFailureCode::InvalidRequest,
17174 "managed Git input is invalid",
17175 ),
17176 GitWorktreeErrorKind::NotRepository => (
17177 NodeFailureCode::NotGitRepository,
17178 "managed source is not a Git repository",
17179 ),
17180 GitWorktreeErrorKind::Conflict => (
17181 NodeFailureCode::WorktreeConflict,
17182 "managed Git identity conflicts with existing repository state",
17183 ),
17184 GitWorktreeErrorKind::Protected => (
17185 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17186 "managed worktree ownership validation failed",
17187 ),
17188 GitWorktreeErrorKind::Dirty => (
17189 NodeFailureCode::WorktreeDirty,
17190 "managed worktree contains uncommitted changes",
17191 ),
17192 GitWorktreeErrorKind::Locked => (
17193 NodeFailureCode::WorktreeLocked,
17194 "managed worktree is locked",
17195 ),
17196 GitWorktreeErrorKind::Failed => (
17197 NodeFailureCode::BackendOperationFailed,
17198 "managed Git operation failed",
17199 ),
17200 };
17201 NodeFailure { code, message: message.to_owned() }
17202}
17203
17204fn managed_spawn_receipt(
17205 resolved: &ResolvedSpawnSpec,
17206 incarnation_id: NodeIncarnationId,
17207 session: SessionAddress,
17208 lease: ManagedWorktreeLeaseSnapshot,
17209 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17210 bundle: Option<ResolvedBundleReceipt>,
17211 context: Option<ResolvedContextPackReceipt>,
17212) -> ManagedWorktreeSpawnReceipt {
17213 let mut managed = resolved.clone();
17214 managed.target.worktree_id = Some(lease.workspace_id.clone());
17215 ManagedWorktreeSpawnReceipt {
17216 spawn: managed.receipt_with_materialization(
17217 incarnation_id,
17218 session,
17219 environment_profile,
17220 bundle,
17221 context,
17222 ),
17223 lease,
17224 }
17225}
17226
17227fn validate_selected_worktree(
17228 source_root: &str,
17229 selected_root: &str,
17230 worktrees: &[NativeGitWorktreeSnapshot],
17231) -> Result<(), NodeFailure> {
17232 if !worktrees
17233 .iter()
17234 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17235 {
17236 return Err(failure(
17237 NodeFailureCode::WorktreeProtected,
17238 "source workspace is not an authoritative non-bare Git worktree root",
17239 ));
17240 }
17241 if !worktrees.iter().any(|worktree| {
17242 worktree_paths_equal(&worktree.path, selected_root)
17243 && !worktree.is_main
17244 && !worktree.is_bare
17245 }) {
17246 return Err(failure(
17247 NodeFailureCode::WorktreeProtected,
17248 "selected workspace is not an eligible Git worktree in the source repository",
17249 ));
17250 }
17251 Ok(())
17252}
17253
17254fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17255 let code = match error.kind() {
17256 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17257 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17258 NodeFailureCode::RepositoryPathUnsafe
17259 }
17260 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17261 WorkspaceFileReadErrorKind::NotRegularFile => {
17262 NodeFailureCode::RepositoryFileNotRegular
17263 }
17264 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17265 NodeFailureCode::RepositoryFileReadFailed
17266 }
17267 WorkspaceFileReadErrorKind::RevisionConflict => {
17268 NodeFailureCode::RepositoryFileRevisionConflict
17269 }
17270 WorkspaceFileReadErrorKind::AlreadyExists
17271 | WorkspaceFileReadErrorKind::ParentNotFound
17272 | WorkspaceFileReadErrorKind::ParentNotDirectory
17273 | WorkspaceFileReadErrorKind::Canceled => {
17274 NodeFailureCode::RepositoryFileReadFailed
17275 }
17276 };
17277 failure(code, "repository file read failed")
17278}
17279
17280fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17281 let code = match error.kind() {
17282 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17283 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17284 NodeFailureCode::RepositoryPathUnsafe
17285 }
17286 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17287 WorkspaceFileReadErrorKind::NotRegularFile => {
17288 NodeFailureCode::RepositoryFileNotRegular
17289 }
17290 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17291 NodeFailureCode::RepositoryFileWriteFailed
17292 }
17293 WorkspaceFileReadErrorKind::RevisionConflict => {
17294 NodeFailureCode::RepositoryFileRevisionConflict
17295 }
17296 WorkspaceFileReadErrorKind::AlreadyExists
17297 | WorkspaceFileReadErrorKind::ParentNotFound
17298 | WorkspaceFileReadErrorKind::ParentNotDirectory
17299 | WorkspaceFileReadErrorKind::Canceled => {
17300 NodeFailureCode::RepositoryFileWriteFailed
17301 }
17302 };
17303 failure(code, "repository file write failed")
17304}
17305
17306async fn settle_workspace_entry_create<T>(
17307 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17308 commit_state: Arc<AtomicU8>,
17309 deadline: Duration,
17310 timeout_message: &'static str,
17311 task_failure_message: &'static str,
17312) -> Result<T, NodeFailure> {
17313 let joined = match timeout(deadline, &mut task).await {
17314 Ok(joined) => joined,
17315 Err(_) => {
17316 if commit_state
17317 .compare_exchange(
17318 WORKSPACE_ENTRY_CREATE_PENDING,
17319 WORKSPACE_ENTRY_CREATE_CANCELED,
17320 Ordering::AcqRel,
17321 Ordering::Acquire,
17322 )
17323 .is_ok()
17324 {
17325 return Err(failure(
17326 NodeFailureCode::RepositoryEntryCreateTimedOut,
17327 timeout_message,
17328 ));
17329 }
17330 debug_assert_eq!(
17331 commit_state.load(Ordering::Acquire),
17332 WORKSPACE_ENTRY_CREATE_COMMITTING,
17333 );
17334 task.await
17335 }
17336 };
17337 joined
17338 .map_err(|_| {
17339 failure(
17340 NodeFailureCode::RepositoryEntryCreateFailed,
17341 task_failure_message,
17342 )
17343 })?
17344 .map_err(workspace_entry_create_failure)
17345}
17346
17347fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17348 let code = match error.kind() {
17349 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17350 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17351 NodeFailureCode::RepositoryPathUnsafe
17352 }
17353 WorkspaceFileReadErrorKind::AlreadyExists => {
17354 NodeFailureCode::RepositoryEntryAlreadyExists
17355 }
17356 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17357 NodeFailureCode::RepositoryParentNotFound
17358 }
17359 WorkspaceFileReadErrorKind::ParentNotDirectory
17360 | WorkspaceFileReadErrorKind::NotRegularFile => {
17361 NodeFailureCode::RepositoryParentNotDirectory
17362 }
17363 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17364 NodeFailureCode::RepositoryEntryCreateFailed
17365 }
17366 WorkspaceFileReadErrorKind::RevisionConflict => {
17367 NodeFailureCode::RepositoryEntryCreateFailed
17368 }
17369 WorkspaceFileReadErrorKind::Canceled => {
17370 NodeFailureCode::RepositoryEntryCreateTimedOut
17371 }
17372 };
17373 failure(code, "repository entry create failed")
17374}
17375
17376fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17377 let value = digest(&SHA256, bytes)
17378 .as_ref()
17379 .iter()
17380 .map(|byte| format!("{byte:02x}"))
17381 .collect();
17382 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17383}
17384
17385fn managed_spawn_request_digest_v2(
17386 request: &ManagedWorktreeSpawnRequestV2,
17387) -> Result<String, NodeFailure> {
17388 let encoded = serde_json::to_vec(request).map_err(|_| {
17389 failure(
17390 NodeFailureCode::InvalidRequest,
17391 "managed worktree V2 request could not be canonicalized",
17392 )
17393 })?;
17394 let mut value = String::with_capacity(71);
17395 value.push_str("sha256:");
17396 for byte in digest(&SHA256, &encoded).as_ref() {
17397 use std::fmt::Write as _;
17398 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17399 }
17400 Ok(value)
17401}
17402
17403fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17404 let code = match error {
17405 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17406 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17407 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17408 NativeSessionPreviewError::UnsupportedProvider
17409 | NativeSessionPreviewError::PreviewUnavailable
17410 | NativeSessionPreviewError::SessionNotFound
17411 | NativeSessionPreviewError::AmbiguousSession => {
17412 NodeFailureCode::BackendOperationFailed
17413 }
17414 };
17415 failure(code, "native session preview failed")
17416}
17417
17418fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17419 let code = match error.kind() {
17420 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17421 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17422 };
17423 failure(code, "host directory browse failed")
17424}
17425
17426fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17427 let code = match error {
17428 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17429 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17430 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17431 NodeFailureCode::DeliveryStageConflict
17432 }
17433 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17434 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17435 NodeFailureCode::DeliveryChunkOutOfOrder
17436 }
17437 DeliveryStoreError::BlobDigestMismatch => {
17438 NodeFailureCode::DeliveryBlobDigestMismatch
17439 }
17440 DeliveryStoreError::BundleDigestMismatch => {
17441 NodeFailureCode::DeliveryBundleDigestMismatch
17442 }
17443 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17444 DeliveryStoreError::Unavailable
17445 | DeliveryStoreError::Corrupt
17446 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17447 };
17448 failure(code, "delivery operation failed")
17449}
17450
17451fn validate_workspace_root(
17452 workspace_id: &WorkspaceId,
17453 canonical_root: &str,
17454) -> Result<(), NodeServerError> {
17455 #[cfg(windows)]
17456 if canonical_root.starts_with(r"\\") {
17457 return Err(NodeServerError::InvalidWorkspaceRoot {
17458 workspace_id: workspace_id.clone(),
17459 path: canonical_root.to_owned(),
17460 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17461 });
17462 }
17463 if !platform::workspace_root_supported(canonical_root)
17464 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17465 {
17466 return Err(NodeServerError::InvalidWorkspaceRoot {
17467 workspace_id: workspace_id.clone(),
17468 path: canonical_root.to_owned(),
17469 message: "canonical path must be absolute, bounded, and free of control characters"
17470 .to_owned(),
17471 });
17472 }
17473 Ok(())
17474}
17475
17476fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17477 NodeFailure {
17478 code,
17479 message: node_failure_category(code).to_owned(),
17480 }
17481}
17482
17483fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17484 let code = match error {
17485 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17486 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17487 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17488 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17489 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17490 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17491 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17492 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17493 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17494 };
17495 failure(code, "harness MCP proxy operation failed")
17496}
17497
17498static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17502
17503fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17510 let trace = launch.trace.as_ref()?;
17511 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17512 if trace_dir.is_empty() {
17513 return None;
17514 }
17515 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17516 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17517 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17518}
17519
17520fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17521 let remaining = deadline.saturating_duration_since(Instant::now());
17522 if remaining.is_zero() {
17523 Err(failure(
17524 NodeFailureCode::SpawnDeadlineExceeded,
17525 "spawn deadline elapsed",
17526 ))
17527 } else {
17528 Ok(remaining)
17529 }
17530}
17531
17532fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17533 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17534 match deadline {
17535 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17536 None => Ok(bounded),
17537 }
17538}
17539
17540fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17541 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17542 failure(
17543 NodeFailureCode::SpawnDeadlineExceeded,
17544 "spawn dispatch exceeded the spawn deadline",
17545 )
17546 } else {
17547 error
17548 }
17549}
17550
17551fn spawn_runtime_capabilities(
17552 capabilities: &SpawnRequiredCapabilities,
17553) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17554 capabilities
17555 .iter()
17556 .map(|capability| match capability.as_str() {
17557 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17558 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17559 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17560 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17561 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17562 }
17563 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17564 _ => Err(failure(
17565 NodeFailureCode::UnsupportedSpawnCapability,
17566 "spawn required capability is unknown",
17567 )),
17568 })
17569 .collect()
17570}
17571
17572fn persistence_failure(_error: io::Error) -> NodeFailure {
17573 NodeFailure {
17574 code: NodeFailureCode::BackendOperationFailed,
17575 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17576 }
17577}
17578
17579fn node_failure_category(code: NodeFailureCode) -> &'static str {
17580 match code {
17581 NodeFailureCode::InvalidRequest => "invalid-request",
17582 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17583 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17584 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17585 NodeFailureCode::ReservationConflict => "reservation-conflict",
17586 NodeFailureCode::ReservationExpired => "reservation-expired",
17587 NodeFailureCode::BindingMismatch => "binding-mismatch",
17588 NodeFailureCode::NotActivated => "not-activated",
17589 NodeFailureCode::CallNotFound => "call-not-found",
17590 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17591 NodeFailureCode::ResponseTooLarge => "response-too-large",
17592 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17593 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17594 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17595 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17596 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17597 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17598 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17599 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17600 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17601 NodeFailureCode::Unauthorized => "unauthorized",
17602 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17603 NodeFailureCode::ControllerBusy => "controller-busy",
17604 NodeFailureCode::ControllerRequired => "controller-required",
17605 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17606 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17607 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17608 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17609 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17610 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17611 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17612 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17613 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17614 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17615 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17616 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17617 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17618 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17619 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17620 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17621 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17622 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17623 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17624 NodeFailureCode::GitReadFailed => "git-read-failed",
17625 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17626 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17627 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17628 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17629 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17630 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17631 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17632 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17633 NodeFailureCode::TurnInFlight => "turn-in-flight",
17634 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17635 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17636 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17637 "unsupported-network-allowlist-mapping"
17638 }
17639 NodeFailureCode::BrowserStationProbeUnavailable => {
17640 "browser-station-probe-unavailable"
17641 }
17642 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17643 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17644 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17645 "environment-profile-binding-mismatch"
17646 }
17647 NodeFailureCode::UnknownBundle => "unknown-bundle",
17648 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17649 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17650 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17651 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17652 NodeFailureCode::ContextPackMaterializationFailed => {
17653 "context-pack-materialization-failed"
17654 }
17655 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17656 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17657 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17658 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17659 NodeFailureCode::LastWorkspace => "last-workspace",
17660 NodeFailureCode::NotGitRepository => "not-git-repository",
17661 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17662 NodeFailureCode::WorktreeProtected => "worktree-protected",
17663 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17664 NodeFailureCode::WorktreeLocked => "worktree-locked",
17665 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17666 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17667 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17668 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17669 "managed-worktree-profile-revision-mismatch"
17670 }
17671 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17672 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17673 "standalone-workspace-recovery-required"
17674 }
17675 NodeFailureCode::UnknownSession => "unknown-session",
17676 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17677 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17678 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17679 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17680 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17681 NodeFailureCode::WorkspaceRegistrationRequired => {
17682 "workspace-registration-required"
17683 }
17684 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17685 NodeFailureCode::StaleGeneration => "stale-generation",
17686 NodeFailureCode::BackendBusy => "backend-busy",
17687 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17688 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17689 NodeFailureCode::ShuttingDown => "shutting-down",
17690 }
17691}
17692
17693fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17694 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17695}
17696
17697fn durable_state_load_error(error: io::Error) -> NodeServerError {
17698 let category = match session_registry::state_load_refusal(&error) {
17699 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17700 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17701 }
17702 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17703 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17704 }
17705 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17706 DURABLE_STATE_CONFLICT_ERROR
17707 }
17708 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17709 };
17710 durable_state_server_error(error, category)
17711}
17712
17713fn unix_time_ms() -> u64 {
17714 std::time::SystemTime::now()
17715 .duration_since(std::time::UNIX_EPOCH)
17716 .unwrap_or_default()
17717 .as_millis()
17718 .min(u64::MAX as u128) as u64
17719}
17720
17721fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17722 if record.provider_session.is_some() {
17723 ManagedSessionState::Dormant
17724 } else {
17725 ManagedSessionState::Unavailable
17726 }
17727}
17728
17729fn active_record_state(
17730 identity_admitted: bool,
17731 provider_session_is_known: bool,
17732) -> ManagedSessionState {
17733 if identity_admitted && !provider_session_is_known {
17734 ManagedSessionState::IdentityPending
17735 } else {
17736 ManagedSessionState::Live
17737 }
17738}
17739
17740fn managed_worktree_record_holders(
17741 records: &[ManagedSessionRecord],
17742) -> Vec<(SessionRecordId, WorkspaceId)> {
17743 records
17744 .iter()
17745 .filter(|record| record.provider_session.is_some())
17746 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17747 .collect()
17748}
17749
17750fn managed_spawn_record_ownership(
17751 binding: &SessionBinding,
17752 effective_runtime_policy: ProviderRuntimePolicy,
17753) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17754 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17755 if effective_runtime_policy.provider_session_identity {
17756 (binding.record_id.clone(), None)
17757 } else {
17758 (None, binding.record_id.clone())
17759 }
17760}
17761
17762#[derive(Debug, Error)]
17763pub enum NodeServerError {
17764 #[error("node endpoint must be a bounded absolute local transport path")]
17765 InvalidEndpoint,
17766 #[error("node access token must contain 1..=4096 bytes")]
17767 InvalidAccessToken,
17768 #[error("node HTTP observer must listen on a loopback address: {0}")]
17769 InvalidApiListen(std::net::SocketAddr),
17770 #[error("node browser bridge must listen on a loopback address: {0}")]
17771 InvalidBridgeListen(std::net::SocketAddr),
17772 #[error("node browser bridge token must contain 1..=4096 bytes")]
17773 InvalidBridgeToken,
17774 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17775 BridgeUnderlayRequiresBridgeListen,
17776 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17777 BridgeUnderlayRequiresBridgeToken,
17778 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17779 InvalidBridgeUnderlayToken,
17780 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17781 InvalidBridgeUnderlayKey,
17782 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17783 InvalidCallHome(std::net::SocketAddr),
17784 #[error("node could not reach its call-home relay: {0}")]
17785 CallHomeTransport(#[source] std::io::Error),
17786 #[error("node durable state path must be an absolute file path: {0}")]
17787 InvalidStatePath(String),
17788 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17789 InvalidHarnessMcpHelper,
17790 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17791 InvalidNetworkAllowlistCatalogPath(String),
17792 #[error("network allowlist catalog could not be read: {0}")]
17793 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17794 #[error("network allowlist catalog line {line}: {message}")]
17795 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17796 #[error("network allowlist catalog: {0}")]
17797 InvalidNetworkAllowlistCatalog(String),
17798 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17799 NetworkAllowlistCatalogCapacity { max: usize },
17800 #[error("the local state directory is unavailable; supply an explicit state path")]
17801 LocalStateDirectoryUnavailable,
17802 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17803 LocalRuntimeDirectoryUnavailable,
17804 #[error("node durable state failed: {0}")]
17805 DurableState(io::Error),
17806 #[error("node requires at least one configured workspace")]
17807 NoWorkspaces,
17808 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17809 InvalidWorkspaceRoot {
17810 workspace_id: WorkspaceId,
17811 path: String,
17812 message: String,
17813 },
17814 #[error("duplicate workspace ID: {0}")]
17815 DuplicateWorkspaceId(WorkspaceId),
17816 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17817 DuplicateWorkspaceRoot {
17818 first: WorkspaceId,
17819 second: WorkspaceId,
17820 root: String,
17821 },
17822 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17823 ManagedWorktreeProfileRequired(WorkspaceId),
17824 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17825 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17826 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17827 DuplicateManagedWorktreeProfile {
17828 workspace_id: WorkspaceId,
17829 profile_id: WorktreeProfileId,
17830 },
17831 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17832 ManagedWorktreeProfileCapacity {
17833 workspace_id: WorkspaceId,
17834 max: usize,
17835 },
17836 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17837 InvalidManagedWorktreeProfile {
17838 workspace_id: WorkspaceId,
17839 profile_id: WorktreeProfileId,
17840 message: String,
17841 },
17842 #[error("duplicate node environment profile: {0}")]
17843 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17844 #[error("node environment profile provider is unavailable: {0}")]
17845 EnvironmentProfileProviderUnavailable(AgentId),
17846 #[error("node environment profile capacity is {max}")]
17847 EnvironmentProfileCapacity { max: usize },
17848 #[error("node environment profiles reuse native profile ID '{0}'")]
17849 DuplicateNativeEnvironmentProfile(String),
17850 #[error("node native environment profile is invalid: {0}")]
17851 NativeEnvironmentProfile(String),
17852 #[error("node session-environment materialization root must be absolute")]
17853 InvalidSessionEnvironmentRoot,
17854 #[error("node session-environment materializer is unavailable")]
17855 SessionEnvironmentMaterializerRequired,
17856 #[error("node session-environment materializer failed to initialize")]
17857 SessionEnvironmentMaterializer,
17858 #[error("node bundle catalog is invalid: {0}")]
17859 BundleCatalog(String),
17860 #[error("node delivery store failed to initialize: {0}")]
17861 DeliveryStore(String),
17862 #[error("node context pack store failed to initialize")]
17863 ContextPackStore,
17864 #[error("active agent registry failed: {0}")]
17865 Registry(String),
17866 #[error("node provider contract manifest is invalid: {0}")]
17867 ProviderContractManifest(String),
17868 #[error("named pipe I/O failed: {0}")]
17869 Io(#[from] io::Error),
17870 #[error("node HTTP observer failed: {0}")]
17871 HttpApi(io::Error),
17872 #[error("node browser bridge failed: {0}")]
17873 BridgeApi(io::Error),
17874 #[error(transparent)]
17875 Frame(#[from] FrameError),
17876 #[error("node handshake failed: {0}")]
17877 Handshake(String),
17878 #[error("node authentication frame was not received before the bounded deadline")]
17879 AuthenticationTimedOut,
17880 #[error("node authentication primitive failed: {0}")]
17881 Authentication(String),
17882 #[error("node incarnation identity generation failed: {0}")]
17883 IncarnationIdentity(String),
17884 #[error("node pre-authentication limiter is closed")]
17885 PreauthClosed,
17886 #[error("node authenticated connection limit was reached")]
17887 AuthenticatedConnectionLimit,
17888 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17889 SignalStreamClosed,
17890 #[error("node stop-all dispatch failed: {0:?}")]
17891 ShutdownDispatch(NodeFailure),
17892 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17893 ShutdownTimedOut { active_native_sessions: usize },
17894}
17895
17896impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
17897 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
17898 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
17899 match error {
17900 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
17901 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
17902 E::InvalidEntry { line, message } => {
17903 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
17904 }
17905 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
17906 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
17907 }
17908 }
17909}
17910
17911#[cfg(test)]
17912mod agent_stream_blocked_tests {
17913 use super::*;
17914 use gate4agent_types::{
17915 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
17916 ProviderSource,
17917 };
17918
17919 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
17920 ProviderSource {
17921 family,
17922 binding: AdapterBinding::new(
17923 AdapterId::new(adapter).unwrap(),
17924 "fixture/v1",
17925 AdapterVerification::SyntheticFixture,
17926 )
17927 .unwrap(),
17928 }
17929 }
17930
17931 fn provider_control_event_at(
17932 family: AdapterFamily,
17933 adapter: &str,
17934 source_sequence: u64,
17935 provider_event: ProviderEvent,
17936 ) -> ControlEvent {
17937 ControlEvent {
17938 sequence: 41,
17939 command_id: None,
17940 instance_id: AgentInstanceId(7),
17941 generation: SessionGeneration(3),
17942 event: ControlEventKind::ProviderEvent {
17943 sequence: 8,
17944 source: provider_source(family, adapter),
17945 source_sequence,
17946 event: provider_event,
17947 },
17948 }
17949 }
17950
17951 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
17952 provider_control_event_at(family, "fixture", 9, provider_event)
17953 }
17954
17955 fn denied_host_request(
17956 by: ProviderHostDecisionAuthority,
17957 reason: Option<String>,
17958 ) -> ControlEvent {
17959 provider_control_event_at(
17960 AdapterFamily::Acp,
17961 "claude-code",
17962 2,
17963 ProviderEvent::HostRequestObserved {
17964 method: "terminal/create".to_owned(),
17965 params_json: String::new(),
17966 decision: ProviderHostRequestDecision::Denied { by },
17967 outcome: ProviderHostRequestOutcome::Executed,
17968 reason,
17969 },
17970 )
17971 }
17972
17973 fn blocked(
17974 event: &ControlEvent,
17975 ) -> (
17976 Option<String>,
17977 String,
17978 BlockAuthorityV1,
17979 Option<String>,
17980 String,
17981 Option<String>,
17982 ) {
17983 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
17984 chunk.validate().expect("minted chunk must validate");
17985 let AgentStreamChunkKindV1::Blocked {
17986 correlation_id,
17987 tool_class,
17988 authority,
17989 reason_kind,
17990 reason,
17991 help,
17992 } = chunk.kind
17993 else {
17994 panic!("expected AgentStreamChunkKindV1::Blocked");
17995 };
17996 (correlation_id, tool_class, authority, reason_kind, reason, help)
17997 }
17998
17999 #[test]
18003 fn the_enabled_provider_matrix_is_exact() {
18004 let registry = active_registry().unwrap();
18005 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18006 assert_eq!(
18007 providers
18008 .iter()
18009 .map(|contract| contract.provider.as_str())
18010 .collect::<Vec<_>>(),
18011 vec!["shell", "claude", "codex", "grok", "kimi"],
18013 );
18014 assert!(adapters.iter().all(|contract| {
18015 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18016 }));
18017 }
18018
18019 #[test]
18022 fn gate_denied_host_request_mints_a_blocked_chunk() {
18023 let gate_text =
18024 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18025 let event = denied_host_request(
18026 ProviderHostDecisionAuthority::Gate,
18027 Some(gate_text.to_owned()),
18028 );
18029 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18030 assert_eq!(chunk.source_sequence, 2);
18031 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18032 assert_eq!(authority, BlockAuthorityV1::HostGate);
18033 assert_eq!(reason, gate_text);
18034 assert_eq!(correlation_id, None);
18035 assert_eq!(reason_kind, None);
18036 assert_eq!(help, None);
18037 assert_eq!(tool_class, "Write");
18038 }
18039
18040 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.";
18041
18042 #[test]
18048 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18049 let event = provider_control_event(
18050 AdapterFamily::Acp,
18051 ProviderEvent::ToolCompleted {
18052 id: "toolu_9".to_owned(),
18053 output: CLASSIFIER_SAMPLE.to_owned(),
18054 is_error: true,
18055 duration_ms: None,
18056 agent_id: None,
18057 non_execution_kind: Some("permission-rule".to_owned()),
18058 },
18059 );
18060 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18061 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18062 assert_eq!(reason, "Blocked by classifier");
18063 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18064 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18065 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18066 }
18067
18068 #[test]
18073 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18074 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.";
18075 let event = provider_control_event(
18076 AdapterFamily::Acp,
18077 ProviderEvent::SessionEnded {
18078 result: "JSON-RPC error -32603: Internal error".to_owned(),
18079 cost_usd: None,
18080 is_error: true,
18081 stop_reason: Some(ProviderStopReason::ProviderError {
18082 code: -32603,
18083 message: message.to_owned(),
18084 vendor_code: Some("usageLimitExceeded".to_owned()),
18085 }),
18086 },
18087 );
18088 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18089 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18090 assert_eq!(reason, message);
18091 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18092 assert_eq!(tool_class, "Session");
18093 }
18094
18095 #[test]
18098 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18099 let event = provider_control_event(
18100 AdapterFamily::Acp,
18101 ProviderEvent::SessionEnded {
18102 result: "refusal".to_owned(),
18103 cost_usd: None,
18104 is_error: true,
18105 stop_reason: Some(ProviderStopReason::Refusal),
18106 },
18107 );
18108 let (_, _, authority, _, _, _) = blocked(&event);
18109 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18110 }
18111
18112 #[test]
18116 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18117 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18118 let event = provider_control_event(
18119 AdapterFamily::Acp,
18120 ProviderEvent::ToolCompleted {
18121 id: "toolu_1".to_owned(),
18122 output: "command exited with status 1".to_owned(),
18123 is_error: true,
18124 duration_ms: Some(12),
18125 agent_id: None,
18126 non_execution_kind,
18127 },
18128 );
18129 assert!(agent_stream_chunk(&event).is_none());
18130 }
18131 let narrative = provider_control_event(
18132 AdapterFamily::Acp,
18133 ProviderEvent::ToolCompleted {
18134 id: "toolu_2".to_owned(),
18135 output: "the harness blocked the write, approval needed".to_owned(),
18136 is_error: false,
18137 duration_ms: Some(3),
18138 agent_id: None,
18139 non_execution_kind: None,
18140 },
18141 );
18142 assert!(agent_stream_chunk(&narrative).is_none());
18143 }
18144
18145 #[test]
18149 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18150 for (decision, outcome) in [
18151 (
18152 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18153 ProviderHostRequestOutcome::Executed,
18154 ),
18155 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18156 (
18157 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18158 ProviderHostRequestOutcome::Failed {
18159 error: "terminal/create spawn failed: os error 3".to_owned(),
18160 },
18161 ),
18162 ] {
18163 let event = provider_control_event_at(
18164 AdapterFamily::Acp,
18165 "claude-code",
18166 3,
18167 ProviderEvent::HostRequestObserved {
18168 method: "fs/read_text_file".to_owned(),
18169 params_json: String::new(),
18170 decision: decision.clone(),
18171 outcome,
18172 reason: None,
18173 },
18174 );
18175 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18176 }
18177 }
18178
18179 #[test]
18183 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18184 for (by, expected_authority, expected_reason) in [
18185 (
18186 ProviderHostDecisionAuthority::Operator,
18187 BlockAuthorityV1::Operator,
18188 "declined by the operator",
18189 ),
18190 (
18191 ProviderHostDecisionAuthority::DeadlinePolicy,
18192 BlockAuthorityV1::HostDeadline,
18193 "denied by host policy after the operator deadline expired",
18194 ),
18195 ] {
18196 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18197 assert_eq!(authority, expected_authority);
18198 assert_eq!(reason, expected_reason);
18199 }
18200 }
18201
18202 #[test]
18206 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18207 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18208 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18209 ProviderHostDecisionAuthority::Policy,
18210 Some(oversized),
18211 ));
18212 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18213 }
18214}
18215
18216#[cfg(test)]
18217mod standalone_capability_platform_tests {
18218 use super::*;
18219
18220 #[test]
18221 fn standalone_workspace_capability_requires_windows_identity_guards() {
18222 let capabilities = baseline_capabilities().unwrap();
18223 let advertised = capabilities.iter().any(|capability| {
18224 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18225 });
18226 assert_eq!(advertised, cfg!(windows));
18227
18228 let request = NodeRequest::CreateStandaloneWorkspace {
18229 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18230 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18231 initial_branch: None,
18232 };
18233 assert_eq!(
18234 request_uses_unnegotiated_capability(&request, &capabilities),
18235 !cfg!(windows),
18236 );
18237 }
18238}
18239
18240
18241#[cfg(test)]
18242mod station_knobs_resolve_tests {
18243 use super::*;
18244 use gate4agent_catalog::EnvMutation;
18245 use gate4agent_runtime_native::{
18246 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18247 NativeLaunchProfileId,
18248 };
18249 use std::ffi::OsString;
18250 use std::sync::Arc;
18251
18252 struct EmptyEnvironmentResolver;
18253
18254 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18255 fn resolve_child_environment(
18256 &self,
18257 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18258 Ok(vec![EnvMutation {
18259 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18260 value: None,
18261 }])
18262 }
18263 }
18264
18265 fn agent(value: &str) -> AgentId {
18266 AgentId::new(value).unwrap()
18267 }
18268
18269 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18270 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18271 let profile_revision =
18272 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18273 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18274 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18275 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18276 provider: agent("claude"),
18277 mode: SessionMode::Pty,
18278 terminal_size: gate4agent_types::TerminalSize {
18279 rows: 24,
18280 columns: 80,
18281 },
18282 prompt: None,
18283 bundle_id: None,
18284 context_id: None,
18285 environment_profile_id: Some(profile_id.clone()),
18286 };
18287 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18288 let catalog = active_registry().unwrap();
18289 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18290 let control = runtime.native_launch_profile_control();
18291 let node_profile = NodeEnvironmentProfile::new(
18292 profile_id.clone(),
18293 profile_revision,
18294 agent("claude"),
18295 [NativeLaunchProfile::new(
18296 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18297 agent("claude"),
18298 TransportKind::Pty,
18299 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18300 Arc::new(EmptyEnvironmentResolver),
18301 )
18302 .unwrap()],
18303 )
18304 .unwrap();
18305 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18306 for native_profile in native_profiles {
18307 runtime.upsert_native_launch_profile(native_profile).unwrap();
18308 }
18309 let workspace = WorkspaceConfig::new(
18310 WorkspaceId::new("primary").unwrap(),
18311 std::env::current_dir().unwrap(),
18312 )
18313 .unwrap();
18314 let shared = NodeShared::new_with_incarnation(
18315 handle,
18316 "fixture-token".to_owned(),
18317 NodeId::new("node-station-knobs-test").unwrap(),
18318 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18319 vec![workspace],
18320 vec![agent("claude")],
18321 ProviderRuntimeStatuses::default(),
18322 None,
18323 Vec::new(),
18324 Vec::new(),
18325 spawn_profiles,
18326 Some(control),
18327 None,
18328 None,
18329 None,
18330 Vec::new(),
18331 Vec::new(),
18332 Vec::new(),
18333 Vec::new(),
18334 None,
18335 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18336 );
18337 shared
18338 .environment_profiles
18339 .write()
18340 .unwrap_or_else(|poisoned| poisoned.into_inner())
18341 .insert(binding.id.clone(), binding);
18342 (shared, spawn_defaults)
18343 }
18344
18345 #[test]
18348 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18349 let (shared, defaults) = station_knobs_fixture();
18350 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18351 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18352 let spec = crate::protocol::SpawnSpec {
18353 target: crate::protocol::SpawnTarget {
18354 node_id: shared.node_id.clone(),
18355 workspace_id: WorkspaceId::new("primary").unwrap(),
18356 worktree_id: None,
18357 },
18358 profile_id: defaults.profile_id.clone(),
18359 expected_profile_revision: defaults.revision.clone(),
18360 overrides: crate::protocol::SpawnOverrides {
18361 network_allowlist: Some(unknown),
18362 browser_profile_id: Some(browser),
18363 ..crate::protocol::SpawnOverrides::default()
18364 },
18365 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18366 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18367 "station-knobs-unknown-allowlist",
18368 )
18369 .unwrap(),
18370 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18371 };
18372 let resolved = spec.resolve(&defaults).unwrap();
18373 let failure = shared
18374 .resolve_environment_profile(&resolved)
18375 .expect_err("unknown allowlist must refuse");
18376 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18377 }
18378
18379 #[test]
18385 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18386 let (shared, defaults) = station_knobs_fixture();
18387 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18388 #[cfg(not(feature = "dig2-station-probe"))]
18389 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18390 shared
18391 .network_allowlist_catalog
18392 .write()
18393 .unwrap_or_else(|poisoned| poisoned.into_inner())
18394 .insert_id(allowlist.clone())
18395 .unwrap();
18396 let spec = crate::protocol::SpawnSpec {
18397 target: crate::protocol::SpawnTarget {
18398 node_id: shared.node_id.clone(),
18399 workspace_id: WorkspaceId::new("primary").unwrap(),
18400 worktree_id: None,
18401 },
18402 profile_id: defaults.profile_id.clone(),
18403 expected_profile_revision: defaults.revision.clone(),
18404 overrides: crate::protocol::SpawnOverrides {
18405 network_allowlist: Some(allowlist.clone()),
18406 #[cfg(not(feature = "dig2-station-probe"))]
18407 browser_profile_id: Some(browser.clone()),
18408 ..crate::protocol::SpawnOverrides::default()
18409 },
18410 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18411 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18412 .unwrap(),
18413 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18414 };
18415 let resolved = spec.resolve(&defaults).unwrap();
18416 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18417 #[cfg(not(feature = "dig2-station-probe"))]
18418 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18419 let receipt = shared
18420 .resolve_environment_profile(&resolved)
18421 .expect("resolve ok")
18422 .expect("env profile present");
18423 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18424 #[cfg(not(feature = "dig2-station-probe"))]
18425 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18426 #[cfg(feature = "dig2-station-probe")]
18427 assert_eq!(receipt.browser_profile_id, None);
18428 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18429
18430 let omitted = crate::protocol::SpawnSpec {
18431 overrides: crate::protocol::SpawnOverrides::default(),
18432 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18433 .unwrap(),
18434 ..spec
18435 };
18436 let resolved_omit = omitted.resolve(&defaults).unwrap();
18437 let bare = shared
18438 .resolve_environment_profile(&resolved_omit)
18439 .expect("resolve ok")
18440 .expect("env profile present");
18441 assert_eq!(bare.network_allowlist, None);
18442 assert_eq!(bare.browser_profile_id, None);
18443 }
18444
18445 #[cfg(feature = "dig2-station-probe")]
18450 #[test]
18451 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18452 let (shared, defaults) = station_knobs_fixture();
18453 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18454 let spec = crate::protocol::SpawnSpec {
18455 target: crate::protocol::SpawnTarget {
18456 node_id: shared.node_id.clone(),
18457 workspace_id: WorkspaceId::new("primary").unwrap(),
18458 worktree_id: None,
18459 },
18460 profile_id: defaults.profile_id.clone(),
18461 expected_profile_revision: defaults.revision.clone(),
18462 overrides: crate::protocol::SpawnOverrides {
18463 browser_profile_id: Some(browser),
18464 ..crate::protocol::SpawnOverrides::default()
18465 },
18466 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18467 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18468 "station-knobs-probe-refuse",
18469 )
18470 .unwrap(),
18471 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18472 };
18473 let resolved = spec.resolve(&defaults).unwrap();
18474 let failure = shared
18475 .resolve_environment_profile(&resolved)
18476 .expect_err("browser_profile with failing probe must refuse");
18477 #[cfg(windows)]
18478 {
18479 assert!(
18480 matches!(
18481 failure.code,
18482 NodeFailureCode::BrowserStationUnreachable
18483 | NodeFailureCode::BrowserStationProbeUnavailable
18484 ),
18485 "unexpected code {:?}",
18486 failure.code
18487 );
18488 }
18489 #[cfg(not(windows))]
18490 {
18491 assert_eq!(
18492 failure.code,
18493 NodeFailureCode::BrowserStationProbeUnavailable
18494 );
18495 }
18496 }
18497
18498 #[cfg(feature = "dig2-station-probe")]
18500 #[test]
18501 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18502 let (shared, defaults) = station_knobs_fixture();
18503 let spec = crate::protocol::SpawnSpec {
18504 target: crate::protocol::SpawnTarget {
18505 node_id: shared.node_id.clone(),
18506 workspace_id: WorkspaceId::new("primary").unwrap(),
18507 worktree_id: None,
18508 },
18509 profile_id: defaults.profile_id.clone(),
18510 expected_profile_revision: defaults.revision.clone(),
18511 overrides: crate::protocol::SpawnOverrides::default(),
18512 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18513 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18514 "station-knobs-probe-omit",
18515 )
18516 .unwrap(),
18517 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18518 };
18519 let resolved = spec.resolve(&defaults).unwrap();
18520 let receipt = shared
18521 .resolve_environment_profile(&resolved)
18522 .expect("omit browser_profile must not probe")
18523 .expect("env profile present");
18524 assert_eq!(receipt.browser_profile_id, None);
18525 }
18526
18527 #[cfg(feature = "dig2-station-probe")]
18531 #[test]
18532 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18533 let (shared, _defaults) = station_knobs_fixture();
18534 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18535 let workspace_id = WorkspaceId::new("primary").unwrap();
18536 let address_a = SessionAddress {
18537 workspace_id: workspace_id.clone(),
18538 session: SessionKey {
18539 instance_id: AgentInstanceId(101),
18540 generation: SessionGeneration(1),
18541 },
18542 };
18543 let address_b = SessionAddress {
18544 workspace_id: workspace_id.clone(),
18545 session: SessionKey {
18546 instance_id: AgentInstanceId(102),
18547 generation: SessionGeneration(1),
18548 },
18549 };
18550 let guard_a = crate::dig2_station_lease::acquire_lease(
18551 &shared.browser_station_leases,
18552 profile.clone(),
18553 address_a.session.instance_id,
18554 1,
18555 )
18556 .expect("first lease");
18557 let busy = crate::dig2_station_lease::acquire_lease(
18558 &shared.browser_station_leases,
18559 profile.clone(),
18560 address_b.session.instance_id,
18561 2,
18562 )
18563 .expect_err("second holder must refuse");
18564 assert!(matches!(
18565 busy,
18566 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18567 ));
18568 shared.bind_session_with_policy(
18570 &address_a,
18571 ProviderRuntimePolicy::raw_pty(),
18572 None,
18573 );
18574 guard_a.retain();
18575 assert!(shared.remove_binding(&address_a).is_some());
18576 crate::dig2_station_lease::acquire_lease(
18577 &shared.browser_station_leases,
18578 profile,
18579 address_b.session.instance_id,
18580 3,
18581 )
18582 .expect("reacquire after clear")
18583 .retain();
18584 assert!(
18585 crate::dig2_station_lease::release_lease_for_holder(
18586 &shared.browser_station_leases,
18587 address_b.session.instance_id,
18588 )
18589 );
18590 }
18591
18592 #[cfg(not(feature = "dig2-station-probe"))]
18595 #[test]
18596 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18597 let (shared, defaults) = station_knobs_fixture();
18598 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18599 let spec = crate::protocol::SpawnSpec {
18600 target: crate::protocol::SpawnTarget {
18601 node_id: shared.node_id.clone(),
18602 workspace_id: WorkspaceId::new("primary").unwrap(),
18603 worktree_id: None,
18604 },
18605 profile_id: defaults.profile_id.clone(),
18606 expected_profile_revision: defaults.revision.clone(),
18607 overrides: crate::protocol::SpawnOverrides {
18608 browser_profile_id: Some(browser.clone()),
18609 ..crate::protocol::SpawnOverrides::default()
18610 },
18611 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18612 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18613 .unwrap(),
18614 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18615 };
18616 let resolved = spec.resolve(&defaults).unwrap();
18617 let receipt = shared
18618 .resolve_environment_profile(&resolved)
18619 .expect("stub resolve")
18620 .expect("env profile");
18621 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18622 }
18623
18624
18625 #[test]
18628 fn network_allowlist_catalog_loads_from_temp_file() {
18629 let unique = std::time::SystemTime::now()
18630 .duration_since(std::time::UNIX_EPOCH)
18631 .unwrap()
18632 .as_nanos();
18633 let path = std::env::temp_dir().join(format!(
18634 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18635 std::process::id(),
18636 ));
18637 std::fs::write(
18638 &path,
18639 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18640 )
18641 .unwrap();
18642 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18643 .expect("temp catalog file must load");
18644 let _ = std::fs::remove_file(&path);
18645 assert_eq!(loaded.len(), 2);
18646 let (shared, defaults) = station_knobs_fixture();
18647 {
18648 let mut catalog = shared
18649 .network_allowlist_catalog
18650 .write()
18651 .unwrap_or_else(|poisoned| poisoned.into_inner());
18652 *catalog = loaded;
18653 }
18654 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18655 let spec = crate::protocol::SpawnSpec {
18656 target: crate::protocol::SpawnTarget {
18657 node_id: shared.node_id.clone(),
18658 workspace_id: WorkspaceId::new("primary").unwrap(),
18659 worktree_id: None,
18660 },
18661 profile_id: defaults.profile_id.clone(),
18662 expected_profile_revision: defaults.revision.clone(),
18663 overrides: crate::protocol::SpawnOverrides {
18664 network_allowlist: Some(allowlist.clone()),
18665 ..crate::protocol::SpawnOverrides::default()
18666 },
18667 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18668 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18669 "station-knobs-catalog-file",
18670 )
18671 .unwrap(),
18672 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18673 };
18674 let resolved = spec.resolve(&defaults).unwrap();
18675 let receipt = shared
18676 .resolve_environment_profile(&resolved)
18677 .expect("registered via file load")
18678 .expect("env profile present");
18679 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18680 }
18681
18682 #[test]
18685 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18686 let (shared, _defaults) = station_knobs_fixture();
18687 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18688 shared
18689 .network_allowlist_catalog
18690 .write()
18691 .unwrap_or_else(|poisoned| poisoned.into_inner())
18692 .insert_id(allowlist.clone())
18693 .unwrap();
18694 let snapshot = shared.snapshot();
18695 let inventory = snapshot
18696 .launch_inventory
18697 .expect("launch inventory present");
18698 let ids = inventory
18699 .network_allowlists
18700 .expect("non-empty catalog surfaces ids");
18701 assert_eq!(ids, vec![allowlist]);
18702 }
18703
18704 #[test]
18708 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18709 let (shared, defaults) = station_knobs_fixture();
18710 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18711 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18712 id: allowlist.clone(),
18713 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18714 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18715 peer: "127.0.0.1:443".to_owned(),
18716 }],
18717 provider_native: Some(
18718 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18719 codex_network_access: Some(true),
18720 },
18721 ),
18722 };
18723 shared
18724 .network_allowlist_catalog
18725 .write()
18726 .unwrap_or_else(|poisoned| poisoned.into_inner())
18727 .insert(entry)
18728 .unwrap();
18729 let spec = crate::protocol::SpawnSpec {
18730 target: crate::protocol::SpawnTarget {
18731 node_id: shared.node_id.clone(),
18732 workspace_id: WorkspaceId::new("primary").unwrap(),
18733 worktree_id: None,
18734 },
18735 profile_id: defaults.profile_id.clone(),
18736 expected_profile_revision: defaults.revision.clone(),
18737 overrides: crate::protocol::SpawnOverrides {
18738 network_allowlist: Some(allowlist),
18739 ..crate::protocol::SpawnOverrides::default()
18740 },
18741 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18742 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18743 "station-knobs-unsupported-mapping",
18744 )
18745 .unwrap(),
18746 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18747 };
18748 let resolved = spec.resolve(&defaults).unwrap();
18749 assert_eq!(resolved.provider.as_str(), "claude");
18750 let failure = shared
18751 .resolve_environment_profile(&resolved)
18752 .expect_err("claude + codex_network_access must refuse");
18753 assert_eq!(
18754 failure.code,
18755 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18756 );
18757 }
18758
18759 #[test]
18763 fn resolve_codex_network_access_overlay_moderate_only() {
18764 let (shared, defaults) = station_knobs_fixture();
18765 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18766 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18767 id: allowlist.clone(),
18768 permits: Vec::new(),
18769 provider_native: Some(
18770 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18771 codex_network_access: Some(true),
18772 },
18773 ),
18774 };
18775 shared
18776 .network_allowlist_catalog
18777 .write()
18778 .unwrap_or_else(|poisoned| poisoned.into_inner())
18779 .insert(entry)
18780 .unwrap();
18781
18782 let overlay = shared
18783 .resolve_codex_network_access_overlay_args(
18784 &agent("codex"),
18785 SessionMode::Pty,
18786 ApprovalLevel::Moderate,
18787 Some(&allowlist),
18788 )
18789 .expect("Moderate Codex maps network_access");
18790 assert_eq!(
18791 overlay,
18792 vec![
18793 std::ffi::OsString::from("-c"),
18794 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18795 ]
18796 );
18797
18798 let acp = shared
18799 .resolve_codex_network_access_overlay_args(
18800 &agent("codex"),
18801 SessionMode::Acp,
18802 ApprovalLevel::Moderate,
18803 Some(&allowlist),
18804 )
18805 .unwrap();
18806 assert_eq!(acp, overlay);
18807
18808 let inline = shared
18809 .resolve_codex_network_access_overlay_args(
18810 &agent("codex"),
18811 SessionMode::Inline,
18812 ApprovalLevel::Moderate,
18813 Some(&allowlist),
18814 )
18815 .unwrap_err();
18816 assert_eq!(
18817 inline.code,
18818 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18819 );
18820
18821 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18822 let spec = crate::protocol::SpawnSpec {
18823 target: crate::protocol::SpawnTarget {
18824 node_id: shared.node_id.clone(),
18825 workspace_id: WorkspaceId::new("primary").unwrap(),
18826 worktree_id: None,
18827 },
18828 profile_id: defaults.profile_id.clone(),
18829 expected_profile_revision: defaults.revision.clone(),
18830 overrides: crate::protocol::SpawnOverrides {
18831 provider: crate::protocol::SpawnOverride::Set {
18832 value: agent("codex"),
18833 },
18834 approval_level: Some(level),
18835 network_allowlist: Some(allowlist.clone()),
18836 ..crate::protocol::SpawnOverrides::default()
18837 },
18838 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18839 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18840 "codex-net-refuse-{}",
18841 match level {
18842 ApprovalLevel::ReadOnly => "ro",
18843 ApprovalLevel::FullAuto => "fa",
18844 _ => "other",
18845 }
18846 ))
18847 .unwrap(),
18848 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18849 };
18850 let resolved = spec.resolve(&defaults).unwrap();
18851 let failure = shared
18852 .resolve_environment_profile(&resolved)
18853 .expect_err("RO/FullAuto must refuse");
18854 assert_eq!(
18855 failure.code,
18856 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18857 );
18858 }
18859 }
18860
18861 #[test]
18865 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18866 let (shared, _defaults) = station_knobs_fixture();
18867 let allowlist =
18868 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18869 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18870 id: allowlist.clone(),
18871 permits: vec![
18872 crate::network_allowlist_catalog::NetworkPermitSketch {
18873 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18874 peer: "github.com:443".to_owned(),
18875 },
18876 crate::network_allowlist_catalog::NetworkPermitSketch {
18877 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18878 peer: "*.npmjs.org".to_owned(),
18879 },
18880 ],
18881 provider_native: None,
18882 };
18883 shared
18884 .network_allowlist_catalog
18885 .write()
18886 .unwrap_or_else(|poisoned| poisoned.into_inner())
18887 .insert(entry)
18888 .unwrap();
18889
18890 let unique = std::time::SystemTime::now()
18891 .duration_since(std::time::UNIX_EPOCH)
18892 .unwrap()
18893 .as_nanos();
18894 let provider_home = std::env::temp_dir().join(format!(
18895 "gate4agent-claude-provider-home-{}-{unique}",
18896 std::process::id(),
18897 ));
18898 std::fs::create_dir_all(&provider_home).unwrap();
18899
18900 let overlay = shared
18901 .resolve_network_allowlist_overlay_args(
18902 &agent("claude"),
18903 SessionMode::Pty,
18904 ApprovalLevel::Moderate,
18905 Some(&allowlist),
18906 Some(provider_home.as_path()),
18907 )
18908 .expect("Claude PTY settings overlay on unix");
18909 let settings_path = provider_home.join(
18910 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
18911 );
18912 assert_eq!(
18913 overlay,
18914 vec![
18915 std::ffi::OsString::from("--settings"),
18916 std::ffi::OsString::from(settings_path.as_os_str()),
18917 ]
18918 );
18919 let body = std::fs::read_to_string(&settings_path).unwrap();
18920 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
18921 assert_eq!(value["sandbox"]["enabled"], true);
18922 assert_eq!(
18923 value["sandbox"]["network"]["allowedDomains"],
18924 serde_json::json!(["github.com", "*.npmjs.org"])
18925 );
18926 assert!(settings_path.starts_with(&provider_home));
18927
18928 let acp = shared
18929 .resolve_network_allowlist_overlay_args(
18930 &agent("claude"),
18931 SessionMode::Acp,
18932 ApprovalLevel::Moderate,
18933 Some(&allowlist),
18934 Some(provider_home.as_path()),
18935 )
18936 .expect_err("Claude ACP must refuse settings overlay");
18937 assert_eq!(
18938 acp.code,
18939 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18940 );
18941
18942 let no_home = shared
18943 .resolve_network_allowlist_overlay_args(
18944 &agent("claude"),
18945 SessionMode::Pty,
18946 ApprovalLevel::Moderate,
18947 Some(&allowlist),
18948 None,
18949 )
18950 .expect_err("Claude overlay requires ProviderHome");
18951 assert_eq!(
18952 no_home.code,
18953 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18954 );
18955
18956 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
18957 shared
18958 .network_allowlist_catalog
18959 .write()
18960 .unwrap_or_else(|poisoned| poisoned.into_inner())
18961 .insert_id(bare.clone())
18962 .unwrap();
18963 let empty = shared
18964 .resolve_network_allowlist_overlay_args(
18965 &agent("claude"),
18966 SessionMode::Pty,
18967 ApprovalLevel::Moderate,
18968 Some(&bare),
18969 Some(provider_home.as_path()),
18970 )
18971 .unwrap();
18972 assert!(empty.is_empty());
18973
18974 let _ = std::fs::remove_dir_all(&provider_home);
18975 }
18976
18977 #[test]
18979 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
18980 let (shared, _defaults) = station_knobs_fixture();
18981 let allowlist =
18982 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
18983 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18984 id: allowlist.clone(),
18985 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18986 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18987 peer: "example.com:443".to_owned(),
18988 }],
18989 provider_native: None,
18990 };
18991 shared
18992 .network_allowlist_catalog
18993 .write()
18994 .unwrap_or_else(|poisoned| poisoned.into_inner())
18995 .insert(entry)
18996 .unwrap();
18997 for provider in ["kimi", "grok"] {
18998 let overlay = shared
18999 .resolve_network_allowlist_overlay_args(
19000 &agent(provider),
19001 SessionMode::Pty,
19002 ApprovalLevel::Moderate,
19003 Some(&allowlist),
19004 None,
19005 )
19006 .unwrap();
19007 assert!(
19008 overlay.is_empty(),
19009 "{provider} must not invent network argv from permits"
19010 );
19011 }
19012 }
19013
19014 #[test]
19016 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19017 let unique = std::time::SystemTime::now()
19018 .duration_since(std::time::UNIX_EPOCH)
19019 .unwrap()
19020 .as_nanos();
19021 let path = std::env::temp_dir().join(format!(
19022 "gate4agent-node-allowlist-json-{}-{unique}.json",
19023 std::process::id(),
19024 ));
19025 std::fs::write(
19026 &path,
19027 r#"{
19028 "schema_version": 2,
19029 "entries": [
19030 {
19031 "id": "egress-default",
19032 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19033 },
19034 { "id": "lab-egress" }
19035 ]
19036 }"#,
19037 )
19038 .unwrap();
19039 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19040 .expect("json v2 catalog must load");
19041 let _ = std::fs::remove_file(&path);
19042 assert_eq!(loaded.len(), 2);
19043 let entry = loaded
19044 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19045 .unwrap();
19046 assert_eq!(entry.permits.len(), 1);
19047 let (shared, defaults) = station_knobs_fixture();
19048 {
19049 let mut catalog = shared
19050 .network_allowlist_catalog
19051 .write()
19052 .unwrap_or_else(|poisoned| poisoned.into_inner());
19053 *catalog = loaded;
19054 }
19055 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19056 let spec = crate::protocol::SpawnSpec {
19057 target: crate::protocol::SpawnTarget {
19058 node_id: shared.node_id.clone(),
19059 workspace_id: WorkspaceId::new("primary").unwrap(),
19060 worktree_id: None,
19061 },
19062 profile_id: defaults.profile_id.clone(),
19063 expected_profile_revision: defaults.revision.clone(),
19064 overrides: crate::protocol::SpawnOverrides {
19065 network_allowlist: Some(allowlist.clone()),
19066 ..crate::protocol::SpawnOverrides::default()
19067 },
19068 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19069 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19070 "station-knobs-catalog-json",
19071 )
19072 .unwrap(),
19073 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19074 };
19075 let resolved = spec.resolve(&defaults).unwrap();
19076 let receipt = shared
19077 .resolve_environment_profile(&resolved)
19078 .expect("permits-only entry ok for claude")
19079 .expect("env profile present");
19080 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19081 let inventory = shared
19082 .snapshot()
19083 .launch_inventory
19084 .expect("launch inventory present");
19085 let ids = inventory.network_allowlists.expect("ids surface");
19086 assert_eq!(
19087 ids,
19088 vec![
19089 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19090 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19091 ]
19092 );
19093 }
19094
19095
19096}
19097
19098
19099#[cfg(test)]
19100mod workspace_environment_collision_tests {
19101 use super::*;
19102 use crate::session_environment::{
19103 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19104 };
19105 use gate4agent_catalog::EnvMutation;
19106 use gate4agent_runtime_native::{
19107 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19108 NativeLaunchProfileId,
19109 };
19110 use std::ffi::OsString;
19111 use std::sync::Arc;
19112
19113 struct EmptyEnvironmentResolver;
19114
19115 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19116 fn resolve_child_environment(
19117 &self,
19118 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19119 Ok(vec![EnvMutation {
19120 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19121 value: None,
19122 }])
19123 }
19124 }
19125
19126 fn agent(value: &str) -> AgentId {
19127 AgentId::new(value).unwrap()
19128 }
19129
19130 fn claude_config_dir_collision_fixture() -> (
19132 NodeShared,
19133 ResolvedEnvironmentProfileReceipt,
19134 ResolvedEnvironmentProfileReceipt,
19135 ) {
19136 let catalog = active_registry().unwrap();
19137 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19138 let control = runtime.native_launch_profile_control();
19139 let workspace = WorkspaceConfig::new(
19140 WorkspaceId::new("primary").unwrap(),
19141 std::env::current_dir().unwrap(),
19142 )
19143 .unwrap();
19144 let shared = NodeShared::new_with_incarnation(
19145 handle,
19146 "fixture-token".to_owned(),
19147 NodeId::new("node-env-collision-test").unwrap(),
19148 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19149 vec![workspace],
19150 vec![agent("claude")],
19151 ProviderRuntimeStatuses::default(),
19152 None,
19153 Vec::new(),
19154 Vec::new(),
19155 SpawnProfileRegistry::default(),
19156 Some(control),
19157 None,
19158 None,
19159 None,
19160 Vec::new(),
19161 Vec::new(),
19162 Vec::new(),
19163 Vec::new(),
19164 None,
19165 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19166 );
19167
19168 let mut receipts = Vec::new();
19169 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19170 let profile_id =
19171 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19172 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19173 "claude-config-dir-{suffix}-r1"
19174 ))
19175 .unwrap();
19176 let materialization = NodeSessionMaterializationProfile::new(
19177 Vec::new(),
19178 vec![NodeSessionPathBinding::new(
19179 "CLAUDE_CONFIG_DIR",
19180 NodeSessionPathClass::ProviderHome,
19181 )
19182 .unwrap()],
19183 Vec::new(),
19184 )
19185 .unwrap();
19186 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19187 profile_id.clone(),
19188 profile_revision.clone(),
19189 agent("claude"),
19190 [NativeLaunchProfile::new(
19191 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19192 .unwrap(),
19193 agent("claude"),
19194 TransportKind::Pty,
19195 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19196 Arc::new(EmptyEnvironmentResolver),
19197 )
19198 .unwrap()],
19199 Some(materialization),
19200 )
19201 .unwrap();
19202 let (binding, native_profiles, materialization) = node_profile.into_parts();
19203 for native_profile in native_profiles {
19204 runtime.upsert_native_launch_profile(native_profile).unwrap();
19205 }
19206 shared
19207 .environment_profiles
19208 .write()
19209 .unwrap_or_else(|poisoned| poisoned.into_inner())
19210 .insert(binding.id.clone(), binding);
19211 shared
19212 .environment_materialization_profiles
19213 .write()
19214 .unwrap_or_else(|poisoned| poisoned.into_inner())
19215 .insert(profile_id.clone(), materialization.unwrap());
19216 receipts.push(ResolvedEnvironmentProfileReceipt {
19217 profile_id,
19218 profile_revision,
19219 network_allowlist: None,
19220 browser_profile_id: None,
19221 });
19222 }
19223 let profile_b = receipts.pop().unwrap();
19224 let profile_a = receipts.pop().unwrap();
19225 (shared, profile_a, profile_b)
19226 }
19227
19228 #[test]
19229 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19230 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19231 let primary = WorkspaceId::new("primary").unwrap();
19232 let worktree = WorkspaceId::new("worktree-b").unwrap();
19233 let address = SessionAddress {
19234 workspace_id: primary.clone(),
19235 session: SessionKey {
19236 instance_id: AgentInstanceId(41),
19237 generation: SessionGeneration(1),
19238 },
19239 };
19240 shared.bind_session_with_policy(
19241 &address,
19242 ProviderRuntimePolicy::raw_pty(),
19243 Some(profile_a.clone()),
19244 );
19245
19246 let refused = shared
19247 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19248 .unwrap_err();
19249 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19250
19251 let refused_none = shared
19253 .ensure_workspace_environment_profile_compatible(&primary, None)
19254 .unwrap_err();
19255 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19256
19257 shared
19259 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19260 .unwrap();
19261
19262 shared
19264 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19265 .unwrap();
19266 }
19267
19268 #[tokio::test]
19269 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19270 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19271 let primary = WorkspaceId::new("primary").unwrap();
19272 let address = SessionAddress {
19273 workspace_id: primary.clone(),
19274 session: SessionKey {
19275 instance_id: AgentInstanceId(41),
19276 generation: SessionGeneration(1),
19277 },
19278 };
19279 shared.bind_session_with_policy(
19280 &address,
19281 ProviderRuntimePolicy::raw_pty(),
19282 Some(profile_a),
19283 );
19284
19285 let failure = shared
19286 .spawn_session_with_deadline(
19287 primary,
19288 agent("claude"),
19289 SessionMode::Pty,
19290 gate4agent_types::TerminalSize {
19291 rows: 24,
19292 columns: 80,
19293 },
19294 None,
19295 Some(profile_b),
19296 None,
19297 None,
19298 None,
19299 None,
19300 SpawnRecordPolicy::ProviderIdentityOnly,
19301 Some(Instant::now() + Duration::from_secs(30)),
19302 &[],
19303 None,
19304 ApprovalLevel::default(),
19305 None,
19306 None,
19307 )
19308 .await
19309 .unwrap_err();
19310 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19311 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19313 assert!(shared.handle.snapshot().sessions.is_empty());
19314 }
19315}
19316
19317#[cfg(test)]
19318mod spawn_characterization_tests {
19319 use super::*;
19320 use crate::session_environment::{
19321 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19322 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19323 NodeSessionPathClass,
19324 };
19325 use gate4agent_catalog::EnvMutation;
19326 use gate4agent_runtime_native::{
19327 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19328 NativeLaunchProfile, NativeLaunchProfileId,
19329 };
19330 use std::ffi::OsString;
19331 use std::sync::Arc;
19332
19333 struct EmptyEnvironmentResolver;
19334
19335 struct FixtureSecretResolver {
19336 deny: Arc<AtomicBool>,
19337 }
19338
19339 impl NodeSecretResolver for FixtureSecretResolver {
19340 fn resolve(
19341 &self,
19342 _reference: &NodeSecretReference,
19343 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19344 if self.deny.load(Ordering::Acquire) {
19345 Err(NodeSecretResolveError::Denied)
19346 } else {
19347 NodeSecretValue::text("fixture-secret")
19348 .map_err(|_| NodeSecretResolveError::Unavailable)
19349 }
19350 }
19351 }
19352
19353 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19354 fn resolve_child_environment(
19355 &self,
19356 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19357 Ok(vec![EnvMutation {
19358 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19359 value: None,
19360 }])
19361 }
19362 }
19363
19364 fn agent(value: &str) -> AgentId {
19365 AgentId::new(value).unwrap()
19366 }
19367
19368 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19369 let unique = std::time::SystemTime::now()
19370 .duration_since(std::time::UNIX_EPOCH)
19371 .unwrap()
19372 .as_nanos();
19373 std::env::temp_dir().join(format!(
19374 "gate4agent-node-{label}-{}-{unique}",
19375 std::process::id(),
19376 ))
19377 }
19378
19379 fn materialization_test_shared(
19380 deny_secret: bool,
19381 ) -> (
19382 NodeShared,
19383 NativeRuntime,
19384 ResolvedEnvironmentProfileReceipt,
19385 PathBuf,
19386 Arc<AtomicBool>,
19387 ) {
19388 let workspace = WorkspaceConfig::new(
19389 WorkspaceId::new("primary").unwrap(),
19390 std::env::current_dir().unwrap(),
19391 )
19392 .unwrap();
19393 materialization_test_shared_with_workspace(deny_secret, workspace)
19394 }
19395
19396 fn materialization_test_shared_with_workspace(
19397 deny_secret: bool,
19398 workspace: WorkspaceConfig,
19399 ) -> (
19400 NodeShared,
19401 NativeRuntime,
19402 ResolvedEnvironmentProfileReceipt,
19403 PathBuf,
19404 Arc<AtomicBool>,
19405 ) {
19406 let root = temporary_workspace_root(if deny_secret {
19407 "materialization-denied"
19408 } else {
19409 "materialization-ready"
19410 });
19411 std::fs::create_dir_all(&root).unwrap();
19412 let materialization_root = root.join("session-environments");
19413 let deny = Arc::new(AtomicBool::new(deny_secret));
19414 let resolver = Arc::new(FixtureSecretResolver {
19415 deny: Arc::clone(&deny),
19416 });
19417 let materializer = SessionEnvironmentMaterializer::new(
19418 materialization_root,
19419 resolver,
19420 )
19421 .unwrap();
19422 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19423 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19424 "materialized-claude-r1",
19425 )
19426 .unwrap();
19427 let materialization = NodeSessionMaterializationProfile::new(
19428 vec![NodeSessionEnvironmentMutation::SetSecret {
19429 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19430 reference: NodeSecretReference::new("fixture-token").unwrap(),
19431 }],
19432 vec![NodeSessionPathBinding::new(
19433 "GATE4AGENT_PROVIDER_HOME",
19434 NodeSessionPathClass::ProviderHome,
19435 )
19436 .unwrap()],
19437 vec![NodeSessionFile::secret(
19438 NodeSessionPathClass::Config,
19439 "auth/token",
19440 NodeSecretReference::new("fixture-token").unwrap(),
19441 )
19442 .unwrap()],
19443 )
19444 .unwrap();
19445 let catalog = active_registry().unwrap();
19446 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19447 let control = runtime.native_launch_profile_control();
19448 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19449 profile_id.clone(),
19450 profile_revision.clone(),
19451 agent("claude"),
19452 [NativeLaunchProfile::new(
19453 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19454 agent("claude"),
19455 TransportKind::Pty,
19456 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19457 Arc::new(EmptyEnvironmentResolver),
19458 )
19459 .unwrap()],
19460 Some(materialization),
19461 )
19462 .unwrap();
19463 let (binding, native_profiles, materialization) = node_profile.into_parts();
19464 for native_profile in native_profiles {
19465 runtime.upsert_native_launch_profile(native_profile).unwrap();
19466 }
19467 let shared = NodeShared::new_with_incarnation(
19468 handle,
19469 "fixture-token".to_owned(),
19470 NodeId::new("node-materialization-test").unwrap(),
19471 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19472 vec![workspace],
19473 vec![agent("claude")],
19474 ProviderRuntimeStatuses::default(),
19475 None,
19476 Vec::new(),
19477 Vec::new(),
19478 SpawnProfileRegistry::default(),
19479 Some(control),
19480 None,
19481 Some(materializer),
19482 None,
19483 Vec::new(),
19484 Vec::new(),
19485 Vec::new(),
19486 Vec::new(),
19487 None,
19488 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19489 );
19490 shared
19491 .environment_profiles
19492 .write()
19493 .unwrap_or_else(|poisoned| poisoned.into_inner())
19494 .insert(binding.id.clone(), binding);
19495 shared
19496 .environment_materialization_profiles
19497 .write()
19498 .unwrap_or_else(|poisoned| poisoned.into_inner())
19499 .insert(profile_id.clone(), materialization.unwrap());
19500 (
19501 shared,
19502 runtime,
19503 ResolvedEnvironmentProfileReceipt {
19504 profile_id,
19505 profile_revision,
19506 network_allowlist: None,
19507 browser_profile_id: None,
19508 },
19509 root,
19510 deny,
19511 )
19512 }
19513
19514
19515 async fn characterization_spawn(
19518 shared: &NodeShared,
19519 mode: SessionMode,
19520 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19521 required: &[ProviderRuntimeCapability],
19522 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19523 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19524 shared
19525 .spawn_session_with_deadline(
19526 WorkspaceId::new("primary").unwrap(),
19527 agent("claude"),
19528 mode,
19529 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19530 None,
19531 environment_profile,
19532 None,
19533 None,
19534 None,
19535 Some(ProviderRuntimePolicy::raw_pty()),
19536 SpawnRecordPolicy::ProviderIdentityOnly,
19537 Some(Instant::now() + Duration::from_secs(30)),
19538 required,
19539 harness_mcp,
19540 ApprovalLevel::default(),
19541 None,
19542 None,
19543 )
19544 .await
19545 }
19546
19547 fn assert_no_session_state(shared: &NodeShared) {
19548 assert!(shared.handle.snapshot().sessions.is_empty());
19549 assert!(shared
19550 .session_bindings
19551 .lock()
19552 .unwrap_or_else(|poisoned| poisoned.into_inner())
19553 .is_empty());
19554 assert!(shared
19555 .materializations
19556 .lock()
19557 .unwrap_or_else(|poisoned| poisoned.into_inner())
19558 .is_empty());
19559 }
19560
19561 #[tokio::test]
19562 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19563 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19564 let error = characterization_spawn(
19565 &shared,
19566 SessionMode::Pty,
19567 None,
19568 &[ProviderRuntimeCapability::SemanticReadiness],
19569 None,
19570 )
19571 .await
19572 .unwrap_err();
19573 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19574 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19575 assert_no_session_state(&shared);
19576 drop(shared);
19577 drop(runtime);
19578 std::fs::remove_dir_all(root).unwrap();
19579 }
19580
19581 #[tokio::test]
19582 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19583 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19584 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19585 .await
19586 .unwrap_err();
19587 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19588 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19589 assert_no_session_state(&shared);
19590 drop(shared);
19591 drop(runtime);
19592 std::fs::remove_dir_all(root).unwrap();
19593 }
19594
19595 #[test]
19596 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19597 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19598 let address = SessionAddress {
19599 workspace_id: WorkspaceId::new("primary").unwrap(),
19600 session: SessionKey {
19601 instance_id: AgentInstanceId(77),
19602 generation: SessionGeneration(1),
19603 },
19604 };
19605 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19606 let removed = shared.remove_binding(&address).expect("binding removed");
19607 assert!(removed.environment_profile.is_some());
19608 assert!(shared.remove_binding(&address).is_none());
19609 assert_no_session_state(&shared);
19610 drop(shared);
19611 drop(runtime);
19612 std::fs::remove_dir_all(root).unwrap();
19613 }
19614
19615 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19616 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19617 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19618 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19619 .expect("review current test executable as fixture helper");
19620 let registry = HarnessMcpProxyRegistry::new(helper);
19621 let spec = crate::protocol::SpawnSpec {
19622 target: crate::protocol::SpawnTarget {
19623 node_id: NodeId::new("node-characterization").unwrap(),
19624 workspace_id: WorkspaceId::new("primary").unwrap(),
19625 worktree_id: None,
19626 },
19627 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19628 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19629 overrides: crate::protocol::SpawnOverrides::default(),
19630 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19631 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19632 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19633 };
19634 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19635 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19636 let launch = HarnessMcpLaunchV1 {
19637 server_name: "fixture-harness".to_owned(),
19638 args: vec!["--session-proxy".to_owned()],
19639 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19640 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19641 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19642 trace: None,
19643 scrub_env: Vec::new(),
19644 };
19645 let expires = unix_time_ms().saturating_add(30_000);
19646 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19647 registry
19648 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19649 .unwrap()
19650 }
19651
19652 #[tokio::test]
19653 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19654 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19655 let prepared = characterization_prepared_harness("claude").await;
19656 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19657 .await
19658 .unwrap_err();
19659 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19661 let mismatched = characterization_prepared_harness("codex").await;
19662 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19663 .await
19664 .unwrap_err();
19665 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19666 assert_no_session_state(&shared);
19667 drop(shared);
19668 drop(runtime);
19669 std::fs::remove_dir_all(root).unwrap();
19670 }
19671
19672 #[tokio::test]
19673 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19674 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19675 shared.native_launch_profile_control = None;
19676 let prepared = characterization_prepared_harness("claude").await;
19677 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19678 .await
19679 .unwrap_err();
19680 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19681 assert_no_session_state(&shared);
19682 drop(shared);
19683 drop(runtime);
19684 std::fs::remove_dir_all(root).unwrap();
19685 }
19686}
19687
19688#[cfg(all(test, windows))]
19689mod tests {
19690 use super::*;
19691 use gate4agent_types::ProviderActivity;
19692 use crate::session_environment::{
19693 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19694 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19695 NodeSessionPathClass,
19696 };
19697 use gate4agent_catalog::EnvMutation;
19698 use gate4agent_runtime_native::{
19699 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19700 NativeLaunchProfile, NativeLaunchProfileId,
19701 };
19702 use std::ffi::OsString;
19703 use std::sync::Arc;
19704
19705 struct EmptyEnvironmentResolver;
19706
19707 struct FixtureSecretResolver {
19708 deny: Arc<AtomicBool>,
19709 }
19710
19711 impl NodeSecretResolver for FixtureSecretResolver {
19712 fn resolve(
19713 &self,
19714 _reference: &NodeSecretReference,
19715 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19716 if self.deny.load(Ordering::Acquire) {
19717 Err(NodeSecretResolveError::Denied)
19718 } else {
19719 NodeSecretValue::text("fixture-secret")
19720 .map_err(|_| NodeSecretResolveError::Unavailable)
19721 }
19722 }
19723 }
19724
19725 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19726 fn resolve_child_environment(
19727 &self,
19728 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19729 Ok(vec![EnvMutation {
19730 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19731 value: None,
19732 }])
19733 }
19734 }
19735
19736 fn agent(value: &str) -> AgentId {
19737 AgentId::new(value).unwrap()
19738 }
19739
19740 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19741 let catalog = active_registry().unwrap();
19742 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19743 let workspace = WorkspaceConfig::new(
19744 WorkspaceId::new("primary").unwrap(),
19745 std::env::current_dir().unwrap(),
19746 )
19747 .unwrap()
19748 .with_worktree_service_mode(mode);
19749 NodeShared::new(
19750 handle,
19751 "fixture-token".to_owned(),
19752 NodeId::new("node-terminal-test").unwrap(),
19753 vec![workspace],
19754 vec![agent("claude")],
19755 )
19756 }
19757
19758 fn terminal_test_shared() -> NodeShared {
19759 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19760 }
19761
19762 #[cfg(feature = "fixture")]
19763 #[test]
19764 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19765 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19766 assert_eq!(probe.latest_code(), None);
19767
19768 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19769 assert_eq!(
19770 probe.latest_code(),
19771 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19772 );
19773
19774 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19775 assert_eq!(
19776 probe.latest_code(),
19777 Some(NodeFailureCode::SpawnDeadlineExceeded),
19778 );
19779
19780 probe.record(None);
19781 assert_eq!(probe.latest_code(), None);
19782 }
19783
19784 #[test]
19785 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19786 let source = temporary_workspace_root("launch-inventory-source");
19787 let allocation = temporary_workspace_root("launch-inventory-allocation");
19788 std::fs::create_dir_all(&source).unwrap();
19789 std::fs::create_dir_all(&allocation).unwrap();
19790 let workspace_id = WorkspaceId::new("primary").unwrap();
19791 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19792 .unwrap()
19793 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19794 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19795 workspace = workspace
19796 .with_managed_worktree_profile(
19797 ManagedWorktreeProfile::new(
19798 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19799 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19800 &allocation,
19801 "gate4agent",
19802 "HEAD",
19803 ManagedWorktreeRetention::RemoveWhenReleased,
19804 )
19805 .unwrap(),
19806 )
19807 .unwrap();
19808 }
19809 let overflow = workspace.clone().with_managed_worktree_profile(
19810 ManagedWorktreeProfile::new(
19811 WorktreeProfileId::new("overflow").unwrap(),
19812 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19813 &allocation,
19814 "gate4agent",
19815 "HEAD",
19816 ManagedWorktreeRetention::Retain,
19817 )
19818 .unwrap(),
19819 );
19820 assert!(matches!(
19821 overflow,
19822 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19823 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19824 ..
19825 }),
19826 ));
19827
19828 let catalog = active_registry().unwrap();
19829 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19830 let shared = NodeShared::new(
19831 handle,
19832 "fixture-token".to_owned(),
19833 NodeId::new("node-launch-inventory").unwrap(),
19834 vec![workspace],
19835 vec![agent("claude")],
19836 );
19837 let snapshot = shared.snapshot();
19838 let launch = snapshot.launch_inventory.as_ref().unwrap();
19839 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19840 assert!(launch.bundles.as_ref().unwrap().is_empty());
19841 let profiles = snapshot.workspaces[0]
19842 .managed_worktree_profiles
19843 .as_ref()
19844 .unwrap();
19845 assert_eq!(
19846 snapshot.workspaces[0].worktree_service_mode,
19847 Some(WorktreeServiceMode::Managed),
19848 );
19849 assert_eq!(
19850 profiles.profiles.len(),
19851 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19852 );
19853 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19854 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19855 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19856 }
19857 let legacy = snapshot_for_wire(
19858 &shared,
19859 true,
19860 true,
19861 false,
19862 false,
19863 true,
19864 false,
19865 true,
19866 false,
19867 true,
19868 );
19869 assert!(legacy.launch_inventory.is_none());
19870 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19871
19872 drop(shared);
19873 std::fs::remove_dir_all(source).unwrap();
19874 std::fs::remove_dir_all(allocation).unwrap();
19875 }
19876
19877 #[test]
19878 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19879 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19880 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19881 }));
19882 let source = gate4agent_types::ProviderSource {
19883 family: AdapterFamily::PtySemantic,
19884 binding: AdapterBinding::new(
19885 gate4agent_types::AdapterId::new("claude").unwrap(),
19886 "fixture/v1",
19887 gate4agent_types::AdapterVerification::SyntheticFixture,
19888 )
19889 .unwrap(),
19890 };
19891 let provider = ProviderSnapshot {
19892 sequence: 17,
19893 session: Some(ProviderSessionIdentity {
19894 key: ProviderSessionKey::SessionId,
19895 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19896 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
19897 }),
19898 model: Some("PRIVATE_MODEL".to_owned()),
19899 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
19900 completed_turns: 3,
19901 usage: gate4agent_types::TokenUsage {
19902 input_tokens: 10,
19903 output_tokens: 20,
19904 cache_read_tokens: 30,
19905 cache_write_tokens: 40,
19906 reasoning_tokens: 50,
19907 context_window: Some(200_000),
19908 },
19909 lead_activity: ProviderActivity::WaitingForInput,
19910 activity: ProviderActivity::WaitingForInput,
19911 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
19912 active_tools: (0..9)
19913 .map(|index| gate4agent_types::ActiveProviderTool {
19914 id: format!("private-tool-id-{index}"),
19915 name: if index == 0 {
19916 format!(" Read\u{0000}{}", "x".repeat(80))
19917 } else {
19918 format!("Tool-{index}")
19919 },
19920 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
19921 })
19922 .collect(),
19923 interactions: vec![gate4agent_types::ProviderInteraction {
19924 id: gate4agent_types::ProviderInteractionId(17),
19925 source: source.clone(),
19926 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
19927 interaction_kind: ProviderInteractionKind::Approval,
19928 tool_name: " Approve\u{0007}Tool ".to_owned(),
19929 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
19930 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
19931 resume_lead_activity: Some(ProviderActivity::Working),
19932 status: ProviderInteractionStatus::Pending,
19933 }],
19934 subagents: vec![gate4agent_types::ProviderSubagent {
19935 source: source.clone(),
19936 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
19937 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
19938 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
19939 }],
19940 sources: vec![gate4agent_types::ProviderSourceCursor {
19941 source,
19942 sequence: 17,
19943 gap_count: 2,
19944 stale: true,
19945 }],
19946 last_event: Some(ProviderEvent::Error {
19947 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
19948 }),
19949 gap_count: 2,
19950 stale: true,
19951 };
19952 let address = terminal_address(3);
19953 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
19954 assert_eq!(entry.address, address);
19955 assert_eq!(entry.progress.provider_sequence, 17);
19956 assert_eq!(entry.progress.active_tool_count, 9);
19957 assert_eq!(
19958 entry.progress.active_tool_labels.len(),
19959 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
19960 );
19961 assert_eq!(entry.progress.subagent_count, 1);
19962 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
19963 assert!(entry.progress.truncated);
19964 let encoded = serde_json::to_string(&entry).unwrap();
19965 for forbidden in [
19966 "PRIVATE_PROVIDER_SESSION",
19967 "PRIVATE_PROVIDER_PATH",
19968 "PRIVATE_MODEL",
19969 "PRIVATE_TOOL_INVENTORY",
19970 "PRIVATE_CURRENT_PROMPT",
19971 "PRIVATE_TOOL_INPUT_JSON",
19972 "PRIVATE_PROVIDER_REQUEST",
19973 "PRIVATE_INTERACTION_PROMPT",
19974 "PRIVATE_INTERACTION_AGENT",
19975 "PRIVATE_SUBAGENT_ID",
19976 "PRIVATE_SUBAGENT_TYPE",
19977 "PRIVATE_SUBAGENT_DESCRIPTION",
19978 "PRIVATE_PROVIDER_ERROR",
19979 "input_json",
19980 "current_prompt",
19981 "session_id",
19982 ] {
19983 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
19984 }
19985
19986 let snapshot = NodeSnapshot {
19987 node_id: NodeId::new("node-progress-test").unwrap(),
19988 enabled_providers: vec![agent("claude")],
19989 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
19990 workspaces: Vec::new(),
19991 session_records: Vec::new(),
19992 managed_worktrees: Vec::new(),
19993 launch_inventory: None,
19994 agent_progress: vec![entry],
19995 };
19996 let mut legacy_reply = ResponseEnvelope {
19997 request_id: 1,
19998 result: Ok(NodeResponse::Snapshot {
19999 event_sequence: 0,
20000 controller: None,
20001 snapshot: snapshot.clone(),
20002 }),
20003 };
20004 project_response_without_agent_progress(&mut legacy_reply);
20005 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20006 panic!("agent progress projection changed response kind");
20007 };
20008 assert!(legacy.agent_progress.is_empty());
20009 assert_eq!(snapshot.agent_progress.len(), 1);
20010 }
20011
20012 fn terminal_address(generation: u64) -> SessionAddress {
20013 SessionAddress {
20014 workspace_id: WorkspaceId::new("primary").unwrap(),
20015 session: SessionKey {
20016 instance_id: AgentInstanceId(41),
20017 generation: SessionGeneration(generation),
20018 },
20019 }
20020 }
20021
20022 fn spawn_spec_fixture() -> SpawnSpec {
20023 SpawnSpec {
20024 target: crate::protocol::SpawnTarget {
20025 node_id: NodeId::new("node-terminal-test").unwrap(),
20026 workspace_id: WorkspaceId::new("primary").unwrap(),
20027 worktree_id: None,
20028 },
20029 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20030 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20031 .unwrap(),
20032 overrides: crate::protocol::SpawnOverrides::default(),
20033 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20034 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20035 required_capabilities: SpawnRequiredCapabilities::default(),
20036 }
20037 }
20038
20039 fn environment_profile_test_shared(
20040 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20041 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20042 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20043 "local-claude-r1",
20044 )
20045 .unwrap();
20046 let spawn_profiles = SpawnProfileRegistry::new([
20047 crate::protocol::SpawnProfileDefaults {
20048 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20049 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20050 provider: agent("claude"),
20051 mode: SessionMode::Pty,
20052 terminal_size: gate4agent_types::TerminalSize {
20053 rows: 24,
20054 columns: 80,
20055 },
20056 prompt: None,
20057 bundle_id: None,
20058 context_id: None,
20059 environment_profile_id: Some(profile_id.clone()),
20060 },
20061 ])
20062 .unwrap();
20063 let catalog = active_registry().unwrap();
20064 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20065 let control = runtime.native_launch_profile_control();
20066 let node_profile = NodeEnvironmentProfile::new(
20067 profile_id.clone(),
20068 profile_revision.clone(),
20069 agent("claude"),
20070 [NativeLaunchProfile::new(
20071 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20072 agent("claude"),
20073 TransportKind::Pty,
20074 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20075 Arc::new(EmptyEnvironmentResolver),
20076 )
20077 .unwrap()],
20078 )
20079 .unwrap();
20080 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20081 for native_profile in native_profiles {
20082 runtime.upsert_native_launch_profile(native_profile).unwrap();
20083 }
20084 let workspace = WorkspaceConfig::new(
20085 WorkspaceId::new("primary").unwrap(),
20086 std::env::current_dir().unwrap(),
20087 )
20088 .unwrap();
20089 let shared = NodeShared::new_with_incarnation(
20090 handle,
20091 "fixture-token".to_owned(),
20092 NodeId::new("node-terminal-test").unwrap(),
20093 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20094 vec![workspace],
20095 vec![agent("claude")],
20096 ProviderRuntimeStatuses::default(),
20097 None,
20098 Vec::new(),
20099 Vec::new(),
20100 spawn_profiles,
20101 Some(control),
20102 None,
20103 None,
20104 None,
20105 Vec::new(),
20106 Vec::new(),
20107 Vec::new(),
20108 Vec::new(),
20109 None,
20110 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20111 );
20112 shared
20113 .environment_profiles
20114 .write()
20115 .unwrap_or_else(|poisoned| poisoned.into_inner())
20116 .insert(binding.id.clone(), binding);
20117 (
20118 shared,
20119 runtime,
20120 ResolvedEnvironmentProfileReceipt {
20121 profile_id,
20122 profile_revision,
20123 network_allowlist: None,
20124 browser_profile_id: None,
20125 },
20126 )
20127 }
20128
20129 fn materialization_test_shared(
20130 deny_secret: bool,
20131 ) -> (
20132 NodeShared,
20133 NativeRuntime,
20134 ResolvedEnvironmentProfileReceipt,
20135 PathBuf,
20136 Arc<AtomicBool>,
20137 ) {
20138 let workspace = WorkspaceConfig::new(
20139 WorkspaceId::new("primary").unwrap(),
20140 std::env::current_dir().unwrap(),
20141 )
20142 .unwrap();
20143 materialization_test_shared_with_workspace(deny_secret, workspace)
20144 }
20145
20146 fn materialization_test_shared_with_workspace(
20147 deny_secret: bool,
20148 workspace: WorkspaceConfig,
20149 ) -> (
20150 NodeShared,
20151 NativeRuntime,
20152 ResolvedEnvironmentProfileReceipt,
20153 PathBuf,
20154 Arc<AtomicBool>,
20155 ) {
20156 let root = temporary_workspace_root(if deny_secret {
20157 "materialization-denied"
20158 } else {
20159 "materialization-ready"
20160 });
20161 std::fs::create_dir_all(&root).unwrap();
20162 let materialization_root = root.join("session-environments");
20163 let deny = Arc::new(AtomicBool::new(deny_secret));
20164 let resolver = Arc::new(FixtureSecretResolver {
20165 deny: Arc::clone(&deny),
20166 });
20167 let materializer = SessionEnvironmentMaterializer::new(
20168 materialization_root,
20169 resolver,
20170 )
20171 .unwrap();
20172 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20173 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20174 "materialized-claude-r1",
20175 )
20176 .unwrap();
20177 let materialization = NodeSessionMaterializationProfile::new(
20178 vec![NodeSessionEnvironmentMutation::SetSecret {
20179 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20180 reference: NodeSecretReference::new("fixture-token").unwrap(),
20181 }],
20182 vec![NodeSessionPathBinding::new(
20183 "GATE4AGENT_PROVIDER_HOME",
20184 NodeSessionPathClass::ProviderHome,
20185 )
20186 .unwrap()],
20187 vec![NodeSessionFile::secret(
20188 NodeSessionPathClass::Config,
20189 "auth/token",
20190 NodeSecretReference::new("fixture-token").unwrap(),
20191 )
20192 .unwrap()],
20193 )
20194 .unwrap();
20195 let catalog = active_registry().unwrap();
20196 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20197 let control = runtime.native_launch_profile_control();
20198 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20199 profile_id.clone(),
20200 profile_revision.clone(),
20201 agent("claude"),
20202 [NativeLaunchProfile::new(
20203 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20204 agent("claude"),
20205 TransportKind::Pty,
20206 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20207 Arc::new(EmptyEnvironmentResolver),
20208 )
20209 .unwrap()],
20210 Some(materialization),
20211 )
20212 .unwrap();
20213 let (binding, native_profiles, materialization) = node_profile.into_parts();
20214 for native_profile in native_profiles {
20215 runtime.upsert_native_launch_profile(native_profile).unwrap();
20216 }
20217 let shared = NodeShared::new_with_incarnation(
20218 handle,
20219 "fixture-token".to_owned(),
20220 NodeId::new("node-materialization-test").unwrap(),
20221 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20222 vec![workspace],
20223 vec![agent("claude")],
20224 ProviderRuntimeStatuses::default(),
20225 None,
20226 Vec::new(),
20227 Vec::new(),
20228 SpawnProfileRegistry::default(),
20229 Some(control),
20230 None,
20231 Some(materializer),
20232 None,
20233 Vec::new(),
20234 Vec::new(),
20235 Vec::new(),
20236 Vec::new(),
20237 None,
20238 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20239 );
20240 shared
20241 .environment_profiles
20242 .write()
20243 .unwrap_or_else(|poisoned| poisoned.into_inner())
20244 .insert(binding.id.clone(), binding);
20245 shared
20246 .environment_materialization_profiles
20247 .write()
20248 .unwrap_or_else(|poisoned| poisoned.into_inner())
20249 .insert(profile_id.clone(), materialization.unwrap());
20250 (
20251 shared,
20252 runtime,
20253 ResolvedEnvironmentProfileReceipt {
20254 profile_id,
20255 profile_revision,
20256 network_allowlist: None,
20257 browser_profile_id: None,
20258 },
20259 root,
20260 deny,
20261 )
20262 }
20263
20264 #[cfg(feature = "fixture")]
20265 #[derive(Clone, Copy)]
20266 enum CodexHomeProfile {
20267 Missing,
20268 WrongClass,
20269 Exact,
20270 }
20271
20272 #[cfg(feature = "fixture")]
20273 fn codex_bundle_test_shared(
20274 home_profile: CodexHomeProfile,
20275 ) -> (
20276 NodeShared,
20277 NativeRuntime,
20278 ResolvedEnvironmentProfileReceipt,
20279 NodeBundle,
20280 PathBuf,
20281 ) {
20282 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20283 const CLAUDE_MANIFEST: &[u8] =
20284 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20285 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20286 const DIGEST: &str =
20287 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20288
20289 let (shared, mut runtime, _claude_receipt, root, _deny) =
20290 materialization_test_shared(false);
20291 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20292 CodexHomeProfile::Missing => "codex-home-missing",
20293 CodexHomeProfile::WrongClass => "codex-home-wrong",
20294 CodexHomeProfile::Exact => "codex-home-exact",
20295 })
20296 .unwrap();
20297 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20298 "codex-home-r1",
20299 )
20300 .unwrap();
20301 let materialization = match home_profile {
20302 CodexHomeProfile::Missing => None,
20303 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20304 Some(NodeSessionMaterializationProfile::new(
20305 Vec::new(),
20306 vec![NodeSessionPathBinding::new(
20307 "CODEX_HOME",
20308 if matches!(home_profile, CodexHomeProfile::Exact) {
20309 NodeSessionPathClass::ProviderHome
20310 } else {
20311 NodeSessionPathClass::Config
20312 },
20313 )
20314 .unwrap()],
20315 Vec::new(),
20316 )
20317 .unwrap())
20318 }
20319 };
20320 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20321 profile_id.clone(),
20322 profile_revision.clone(),
20323 agent("codex"),
20324 [NativeLaunchProfile::new(
20325 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20326 agent("codex"),
20327 TransportKind::Pty,
20328 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20329 Arc::new(EmptyEnvironmentResolver),
20330 )
20331 .unwrap()],
20332 materialization,
20333 )
20334 .unwrap();
20335 let (binding, native_profiles, materialization) = node_profile.into_parts();
20336 for native_profile in native_profiles {
20337 runtime.upsert_native_launch_profile(native_profile).unwrap();
20338 }
20339 shared
20340 .environment_profiles
20341 .write()
20342 .unwrap_or_else(|poisoned| poisoned.into_inner())
20343 .insert(binding.id.clone(), binding);
20344 if let Some(materialization) = materialization {
20345 shared
20346 .environment_materialization_profiles
20347 .write()
20348 .unwrap_or_else(|poisoned| poisoned.into_inner())
20349 .insert(profile_id.clone(), materialization);
20350 }
20351
20352 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20353 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20354 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20355 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20356 std::fs::write(
20357 bundle_source.join(".claude-plugin/plugin.json"),
20358 CLAUDE_MANIFEST,
20359 )
20360 .unwrap();
20361 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20362 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20363 let bundle = NodeBundle::new(
20364 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20365 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20366 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20367 &bundle_source,
20368 )
20369 .unwrap();
20370 *shared
20371 .bundle_catalog
20372 .write()
20373 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20374 BundleCatalog::new([bundle.clone()]).unwrap();
20375 (
20376 shared,
20377 runtime,
20378 ResolvedEnvironmentProfileReceipt {
20379 profile_id,
20380 profile_revision,
20381 network_allowlist: None,
20382 browser_profile_id: None,
20383 },
20384 bundle,
20385 root,
20386 )
20387 }
20388
20389 #[cfg(feature = "fixture")]
20390 #[test]
20391 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20392 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20393 let (shared, runtime, receipt, bundle, root) =
20394 codex_bundle_test_shared(home_profile);
20395 let error = shared
20396 .bundle_layout(
20397 &agent("codex"),
20398 SessionMode::Pty,
20399 Some(&receipt),
20400 &bundle,
20401 )
20402 .unwrap_err();
20403 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20404 assert!(shared
20405 .materializations
20406 .lock()
20407 .unwrap_or_else(|poisoned| poisoned.into_inner())
20408 .is_empty());
20409 let materialization_root = root.join("session-environments");
20410 assert!(!std::fs::read_dir(&materialization_root)
20411 .unwrap()
20412 .filter_map(Result::ok)
20413 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20414 drop(shared);
20415 drop(runtime);
20416 let _ = std::fs::remove_dir_all(root);
20417 }
20418 }
20419
20420 #[cfg(feature = "fixture")]
20421 #[test]
20422 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20423 let (shared, runtime, receipt, bundle, root) =
20424 codex_bundle_test_shared(CodexHomeProfile::Exact);
20425 let address = SessionAddress {
20426 workspace_id: WorkspaceId::new("primary").unwrap(),
20427 session: SessionKey {
20428 instance_id: AgentInstanceId(310),
20429 generation: SessionGeneration(1),
20430 },
20431 };
20432 let (overlay, guard) = shared
20433 .prepare_session_materialization(
20434 &address,
20435 &agent("codex"),
20436 SessionMode::Pty,
20437 Some(&receipt),
20438 Some(&bundle.receipt()),
20439 None,
20440 None,
20441 )
20442 .unwrap()
20443 .unwrap();
20444 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20445 let ownership = shared
20446 .materializations
20447 .lock()
20448 .unwrap_or_else(|poisoned| poisoned.into_inner())
20449 .get(guard.id().unwrap())
20450 .unwrap()
20451 .clone();
20452 let (profile, _, _) = shared
20453 .materialization_profile(
20454 &agent("codex"),
20455 SessionMode::Pty,
20456 Some(&receipt),
20457 Some(&bundle.receipt()),
20458 None,
20459 )
20460 .unwrap()
20461 .unwrap();
20462 let environment = shared
20463 .session_environment_materializer
20464 .as_ref()
20465 .unwrap()
20466 .resolve_environment(&ownership, &profile)
20467 .unwrap();
20468 assert!(environment.iter().any(|mutation| {
20469 mutation.key == OsString::from("CODEX_HOME")
20470 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20471 }));
20472 assert_eq!(
20473 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20474 .unwrap(),
20475 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20476 );
20477 assert!(std::fs::read_dir(ownership.bundle_root())
20478 .unwrap()
20479 .next()
20480 .is_none());
20481 drop(guard);
20482 drop(shared);
20483 drop(runtime);
20484 let _ = std::fs::remove_dir_all(root);
20485 }
20486
20487 #[cfg(feature = "fixture")]
20488 #[test]
20489 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20490 let (shared, runtime, receipt, bundle, root) =
20491 codex_bundle_test_shared(CodexHomeProfile::Exact);
20492 let bundle_receipt = bundle.receipt();
20493 let address = SessionAddress {
20494 workspace_id: WorkspaceId::new("primary").unwrap(),
20495 session: SessionKey {
20496 instance_id: AgentInstanceId(311),
20497 generation: SessionGeneration(1),
20498 },
20499 };
20500 let (_overlay, guard) = shared
20501 .prepare_session_materialization(
20502 &address,
20503 &agent("codex"),
20504 SessionMode::Pty,
20505 Some(&receipt),
20506 Some(&bundle_receipt),
20507 None,
20508 None,
20509 )
20510 .unwrap()
20511 .unwrap();
20512 let materialization_id = guard.id().unwrap().clone();
20513 let skill_path = shared
20514 .materializations
20515 .lock()
20516 .unwrap_or_else(|poisoned| poisoned.into_inner())
20517 .get(&materialization_id)
20518 .unwrap()
20519 .provider_home()
20520 .join("skills/review-code/SKILL.md");
20521 let record_id = shared
20522 .bind_spawn_session_with_materialization(
20523 &address,
20524 agent("codex"),
20525 SessionMode::Pty,
20526 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20527 SpawnRecordPolicy::ProviderIdentityOnly,
20528 Some(receipt.clone()),
20529 Some(bundle_receipt.clone()),
20530 None,
20531 Some(materialization_id.clone()),
20532 )
20533 .unwrap()
20534 .unwrap();
20535 guard.retain();
20536 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20537
20538 let error = match shared.resolve_record_materialization(
20539 &record_id,
20540 &agent("codex"),
20541 SessionMode::Pty,
20542 Some(&receipt),
20543 Some(&bundle_receipt),
20544 None,
20545 ) {
20546 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20547 Err(error) => error,
20548 };
20549 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20550 assert_eq!(
20551 shared
20552 .materializations
20553 .lock()
20554 .unwrap_or_else(|poisoned| poisoned.into_inner())
20555 .get(&materialization_id)
20556 .unwrap()
20557 .state(),
20558 MaterializationState::RecoveryRequired,
20559 );
20560 drop(shared);
20561 drop(runtime);
20562 let _ = std::fs::remove_dir_all(root);
20563 }
20564
20565 fn bind_record_owned_materialization_fixture(
20566 shared: &NodeShared,
20567 receipt: &ResolvedEnvironmentProfileReceipt,
20568 instance_id: u64,
20569 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20570 let address = SessionAddress {
20571 workspace_id: WorkspaceId::new("primary").unwrap(),
20572 session: SessionKey {
20573 instance_id: AgentInstanceId(instance_id),
20574 generation: SessionGeneration(1),
20575 },
20576 };
20577 let (overlay, materialization_guard) = shared
20578 .prepare_session_materialization(
20579 &address,
20580 &agent("claude"),
20581 SessionMode::Pty,
20582 Some(receipt),
20583 None,
20584 None,
20585 None,
20586 )
20587 .unwrap()
20588 .unwrap();
20589 let materialization_id = materialization_guard.id().unwrap().clone();
20590 let materialization_root = shared
20591 .materializations
20592 .lock()
20593 .unwrap_or_else(|poisoned| poisoned.into_inner())
20594 .get(&materialization_id)
20595 .unwrap()
20596 .root()
20597 .to_path_buf();
20598 let selection = shared
20599 .select_environment_profile(
20600 address.session.instance_id,
20601 &agent("claude"),
20602 SessionMode::Pty,
20603 Some(receipt),
20604 )
20605 .unwrap()
20606 .unwrap();
20607 assert!(shared
20608 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20609 .unwrap()
20610 .is_none());
20611 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20612 let record_id = shared
20613 .bind_spawn_session_with_materialization(
20614 &address,
20615 agent("claude"),
20616 SessionMode::Pty,
20617 policy,
20618 SpawnRecordPolicy::ProviderIdentityOnly,
20619 Some(receipt.clone()),
20620 None,
20621 None,
20622 Some(materialization_id.clone()),
20623 )
20624 .unwrap()
20625 .unwrap();
20626 selection.retain();
20627 materialization_guard.retain();
20628 (address, record_id, materialization_id, materialization_root)
20629 }
20630
20631 #[test]
20632 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20633 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20634 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20635 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20636 }));
20637 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20638 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20639 let mut spec = spawn_spec_fixture();
20640 spec.expected_profile_revision =
20641 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20642 assert!(matches!(
20643 &spec.overrides.environment_profile_id,
20644 crate::protocol::SpawnOverride::Inherit,
20645 ));
20646 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20647 .requires_child_environment_profile_capability());
20648 assert!(request_requires_child_environment_profile(
20649 &shared,
20650 &NodeRequest::SpawnSpec { spec: spec.clone() },
20651 ));
20652
20653 let address = terminal_address(1);
20654 shared.bind_session_with_policy(
20655 &address,
20656 ProviderRuntimePolicy::raw_pty(),
20657 Some(environment_profile.clone()),
20658 );
20659 assert!(request_requires_child_environment_profile(
20660 &shared,
20661 &NodeRequest::Input {
20662 session: address.clone(),
20663 text: "x".to_owned(),
20664 },
20665 ));
20666
20667 let mut record = record("claude", "profile-record");
20668 record.active_session = Some(address.clone());
20669 record.environment_profile = Some(environment_profile.clone());
20670 let record_id = record.record_id.clone();
20671 shared.insert_record(record.clone()).unwrap();
20672 assert!(request_requires_child_environment_profile(
20673 &shared,
20674 &NodeRequest::ResumeSessionRecord {
20675 record_id,
20676 terminal_size: gate4agent_types::TerminalSize {
20677 rows: 24,
20678 columns: 80,
20679 },
20680 initial_prompt: None,
20681 },
20682 ));
20683
20684 let snapshot = snapshot_for_wire(
20685 &shared, false, true, true, true, false, false, false, false, true,
20686 );
20687 assert_eq!(snapshot.session_records.len(), 1);
20688 assert!(snapshot.session_records[0].environment_profile.is_none());
20689 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20690 sequence: 1,
20691 event: NodeEvent::SessionRecordUpserted { record },
20692 });
20693 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20694 panic!("record projection changed the event kind");
20695 };
20696 assert!(record.environment_profile.is_none());
20697
20698 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20699 let mut reply = ResponseEnvelope {
20700 request_id: 1,
20701 result: Ok(NodeResponse::SpawnSpecAccepted {
20702 receipt: resolved.receipt_with_environment(
20703 shared.incarnation_id,
20704 address,
20705 Some(environment_profile),
20706 ),
20707 }),
20708 };
20709 project_response_without_child_environment_profile(&mut reply);
20710 assert_eq!(
20711 reply.result.unwrap_err().code,
20712 NodeFailureCode::UnsupportedCapability,
20713 );
20714 }
20715
20716 #[test]
20717 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20718 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20719 let snapshot = shared.snapshot();
20720 let profiles = snapshot
20721 .launch_inventory
20722 .as_ref()
20723 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20724 .unwrap();
20725 assert_eq!(profiles.len(), 1);
20726 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20727 assert!(snapshot.requires_child_environment_profile_capability());
20728
20729 let mut legacy = snapshot.clone();
20730 clear_snapshot_child_environment_profiles(&mut legacy);
20731 assert!(legacy
20732 .launch_inventory
20733 .as_ref()
20734 .unwrap()
20735 .spawn_profiles
20736 .as_ref()
20737 .unwrap()[0]
20738 .environment_profile
20739 .is_none());
20740 assert!(!legacy.requires_child_environment_profile_capability());
20741
20742 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20743 let bindings = shared
20744 .environment_profiles
20745 .read()
20746 .unwrap_or_else(|poisoned| poisoned.into_inner())
20747 .clone();
20748 let mut provider_mismatch = bindings.clone();
20749 provider_mismatch
20750 .values_mut()
20751 .next()
20752 .unwrap()
20753 .provider = agent("codex");
20754 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20755 let mut mode_mismatch = profile;
20756 mode_mismatch.mode = SessionMode::Inline;
20757 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20758
20759 shared
20760 .environment_profiles
20761 .write()
20762 .unwrap_or_else(|poisoned| poisoned.into_inner())
20763 .clear();
20764 assert!(shared
20765 .snapshot()
20766 .launch_inventory
20767 .unwrap()
20768 .spawn_profiles
20769 .unwrap()
20770 .is_empty());
20771 }
20772
20773 #[test]
20774 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20775 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20776 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20777 }));
20778 let shared = terminal_test_shared();
20779 let bundle = ResolvedBundleReceipt {
20780 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20781 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20782 digest: crate::protocol::SpawnBundleDigest::new(format!(
20783 "sha256:{}",
20784 "0".repeat(64),
20785 ))
20786 .unwrap(),
20787 };
20788 let mut bundled = record("claude", "bundle-record");
20789 bundled.bundle = Some(bundle.clone());
20790 let record_id = bundled.record_id.clone();
20791 shared.insert_record(bundled.clone()).unwrap();
20792 assert!(request_requires_session_bundle(
20793 &shared,
20794 &NodeRequest::ForgetSessionRecord {
20795 record_id: record_id.clone(),
20796 },
20797 ));
20798
20799 let snapshot = snapshot_for_wire(
20800 &shared, false, true, true, true, true, false, false, false, true,
20801 );
20802 assert!(snapshot.session_records[0].bundle.is_none());
20803 let launch = snapshot.launch_inventory.as_ref().unwrap();
20804 assert!(launch.spawn_profiles.is_some());
20805 assert!(launch.bundles.is_none());
20806 let event = project_event_without_session_bundle(NodeEventEnvelope {
20807 sequence: 1,
20808 event: NodeEvent::SessionRecordUpserted { record: bundled },
20809 });
20810 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20811 panic!("record projection changed the event kind");
20812 };
20813 assert!(record.bundle.is_none());
20814
20815 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20816 let mut reply = ResponseEnvelope {
20817 request_id: 1,
20818 result: Ok(NodeResponse::SpawnSpecAccepted {
20819 receipt: resolved.receipt_with_materialization(
20820 shared.incarnation_id,
20821 terminal_address(17),
20822 None,
20823 Some(bundle),
20824 None,
20825 ),
20826 }),
20827 };
20828 project_response_without_session_bundle(&mut reply);
20829 assert_eq!(
20830 reply.result.unwrap_err().code,
20831 NodeFailureCode::UnsupportedCapability,
20832 );
20833 }
20834
20835 #[test]
20836 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20837 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20838 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20839 }));
20840 let context_id = SpawnContextId::new("context-review").unwrap();
20841 let context = ResolvedContextPackReceipt {
20842 id: context_id.clone(),
20843 digest: crate::protocol::SpawnContextDigest::new(format!(
20844 "sha256:{}",
20845 "c".repeat(64),
20846 ))
20847 .unwrap(),
20848 lineage: ContextPackLineageReceipt {
20849 source_node_id: NodeId::new("node-source").unwrap(),
20850 source_session: terminal_address(1),
20851 source_provider: agent("kimi"),
20852 },
20853 source_message_count: 2,
20854 retained_message_count: 2,
20855 byte_len: 256,
20856 truncated: false,
20857 };
20858 assert!(context.is_valid());
20859 let mut bound_record = record("codex", "context-record");
20860 bound_record.context_id = Some(context_id);
20861 bound_record.context = Some(context.clone());
20862 let session = gate4agent_types::SessionSnapshot {
20863 instance_id: AgentInstanceId(91),
20864 agent_id: agent("kimi"),
20865 transport: TransportKind::Pty,
20866 generation: SessionGeneration(3),
20867 status: SessionStatus::Running,
20868 pending_operation: None,
20869 pending_input: None,
20870 process_id: Some(9001),
20871 terminal_size: None,
20872 terminal_frame: None,
20873 terminal_stale: None,
20874 session_options: None,
20875 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20876 history: gate4agent_types::HistorySnapshot {
20877 pending: None,
20878 candidates: vec![HistoryCandidateSummary {
20879 id: "candidate-opaque".to_owned(),
20880 session_id_hint: "session-hint".to_owned(),
20881 modified_at_unix_ms: Some(1),
20882 }],
20883 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20884 loaded: Some(HistorySessionRecord {
20885 session_id: "private-vendor-session".to_owned(),
20886 title: Some("private title".to_owned()),
20887 cwd: Some(r"C:\private\history-root".to_owned()),
20888 model: Some("private-model".to_owned()),
20889 message_count: 2,
20890 completed_turn_count: None,
20891 total_tokens: 19,
20892 messages: vec![
20893 gate4agent_types::HistoryMessageRecord {
20894 role: gate4agent_types::HistoryMessageRole::User,
20895 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20896 },
20897 gate4agent_types::HistoryMessageRecord {
20898 role: gate4agent_types::HistoryMessageRole::Assistant,
20899 text: "private answer".to_owned(),
20900 },
20901 ],
20902 }),
20903 last_error: None,
20904 },
20905 resume: gate4agent_types::ResumeSnapshot::default(),
20906 foreground: gate4agent_types::ForegroundSnapshot::default(),
20907 provider: gate4agent_types::ProviderSnapshot::default(),
20908 screen_state: gate4agent_types::PtyScreenState::default(),
20909 };
20910 let original = NodeSnapshot {
20911 node_id: NodeId::new("node-terminal-test").unwrap(),
20912 enabled_providers: vec![agent("kimi"), agent("codex")],
20913 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20914 workspaces: vec![WorkspaceSnapshot {
20915 workspace_id: WorkspaceId::new("primary").unwrap(),
20916 canonical_root: opaque_windows_path(
20917 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
20918 ),
20919 sessions: vec![session],
20920 worktree_service_mode: None,
20921 managed_worktree_profiles: None,
20922 }],
20923 session_records: vec![bound_record],
20924 managed_worktrees: Vec::new(),
20925 launch_inventory: None,
20926 agent_progress: Vec::new(),
20927 };
20928
20929 let mut negotiated = original.clone();
20930 project_snapshot_history_for_wire(&mut negotiated, true);
20931 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
20932 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
20933 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
20934 assert!(!serde_json::to_string(&negotiated)
20935 .unwrap()
20936 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
20937
20938 let mut legacy = original;
20939 project_snapshot_history_for_wire(&mut legacy, false);
20940 clear_snapshot_context_packs(&mut legacy);
20941 assert_eq!(
20942 legacy.workspaces[0].sessions[0].history,
20943 gate4agent_types::HistorySnapshot::default(),
20944 );
20945 assert!(legacy.session_records[0].context_id.is_none());
20946 assert!(legacy.session_records[0].context.is_none());
20947 let encoded = serde_json::to_string(&legacy).unwrap();
20948 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
20949 assert!(!encoded.contains("context-review"));
20950
20951 let mut reply = ResponseEnvelope {
20952 request_id: 7,
20953 result: Ok(NodeResponse::ContextPackExported { context }),
20954 };
20955 project_response_without_context_pack(&mut reply);
20956 assert_eq!(
20957 reply.result.unwrap_err().code,
20958 NodeFailureCode::UnsupportedCapability,
20959 );
20960 }
20961
20962 #[test]
20963 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
20964 let shared = terminal_test_shared();
20965 let history = HistorySessionRecord {
20966 session_id: "fixture-history".to_owned(),
20967 title: None,
20968 cwd: None,
20969 model: None,
20970 message_count: 1,
20971 completed_turn_count: None,
20972 total_tokens: 0,
20973 messages: vec![gate4agent_types::HistoryMessageRecord {
20974 role: gate4agent_types::HistoryMessageRole::User,
20975 text: "bounded handoff".to_owned(),
20976 }],
20977 };
20978 let pack = NodeContextPack::export(
20979 ContextPackLineageReceipt {
20980 source_node_id: shared.node_id.clone(),
20981 source_session: terminal_address(1),
20982 source_provider: agent("claude"),
20983 },
20984 &history,
20985 )
20986 .unwrap();
20987 let context = shared
20988 .context_catalog
20989 .write()
20990 .unwrap_or_else(|poisoned| poisoned.into_inner())
20991 .insert(pack)
20992 .unwrap();
20993 let mut retained = record("codex", "context-retained-record");
20994 retained.context_id = Some(context.id.clone());
20995 retained.context = Some(context.clone());
20996 let record_id = retained.record_id.clone();
20997 shared.insert_record(retained).unwrap();
20998 assert_eq!(
20999 shared.forget_context_pack(&context.id).unwrap_err().code,
21000 NodeFailureCode::ContextPackBusy,
21001 );
21002 assert!(shared
21003 .context_catalog
21004 .read()
21005 .unwrap_or_else(|poisoned| poisoned.into_inner())
21006 .get(&context.id)
21007 .is_some());
21008 shared
21009 .session_records
21010 .lock()
21011 .unwrap_or_else(|poisoned| poisoned.into_inner())
21012 .records
21013 .remove(&record_id);
21014
21015 let mut spec = spawn_spec_fixture();
21016 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21017 value: context.id.clone(),
21018 };
21019 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21020 let receipt = resolved.receipt_with_materialization(
21021 shared.incarnation_id,
21022 terminal_address(2),
21023 None,
21024 None,
21025 Some(context.clone()),
21026 );
21027 assert!(receipt.context_binding_is_valid());
21028 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21029
21030 shared.forget_context_pack(&context.id).unwrap();
21031 assert!(shared
21032 .context_catalog
21033 .read()
21034 .unwrap_or_else(|poisoned| poisoned.into_inner())
21035 .get(&context.id)
21036 .is_none());
21037 assert!(shared
21038 .replay_spawn_spec(&spec, Instant::now())
21039 .unwrap()
21040 .is_none());
21041 }
21042
21043 #[test]
21044 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21045 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21046 let address = terminal_address(1);
21047 let selection = shared
21048 .select_environment_profile(
21049 address.session.instance_id,
21050 &agent("claude"),
21051 SessionMode::Pty,
21052 Some(&environment_profile),
21053 )
21054 .unwrap()
21055 .unwrap();
21056 shared.bind_session_with_policy(
21057 &address,
21058 ProviderRuntimePolicy::raw_pty(),
21059 Some(environment_profile.clone()),
21060 );
21061 selection.retain();
21062 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21063 assert!(matches!(
21064 runtime.remove_native_launch_profile(&native_id),
21065 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21066 ));
21067 assert!(shared.remove_binding(&address).is_some());
21068 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21069
21070 let mismatched = ResolvedEnvironmentProfileReceipt {
21071 profile_id: environment_profile.profile_id,
21072 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21073 "local-claude-r2",
21074 )
21075 .unwrap(),
21076 network_allowlist: None,
21077 browser_profile_id: None,
21078 };
21079 let error = match shared.select_environment_profile(
21080 AgentInstanceId(42),
21081 &agent("claude"),
21082 SessionMode::Pty,
21083 Some(&mismatched),
21084 ) {
21085 Ok(_) => panic!("mismatched environment profile revision was selected"),
21086 Err(error) => error,
21087 };
21088 assert_eq!(
21089 error.code,
21090 NodeFailureCode::EnvironmentProfileBindingMismatch,
21091 );
21092 assert!(!shared
21093 .session_bindings
21094 .lock()
21095 .unwrap_or_else(|poisoned| poisoned.into_inner())
21096 .contains_key(&AgentInstanceId(42)));
21097 }
21098
21099 #[test]
21100 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21101 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21102 let address = terminal_address(1);
21103 let selection = shared
21104 .select_environment_profile(
21105 address.session.instance_id,
21106 &agent("claude"),
21107 SessionMode::Pty,
21108 Some(&environment_profile),
21109 )
21110 .unwrap()
21111 .unwrap();
21112 let overlay = NativeInstanceLaunchOverlay::new(
21113 agent("claude"),
21114 TransportKind::Pty,
21115 vec![EnvMutation {
21116 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21117 value: Some(OsString::from("fixture-secret")),
21118 }],
21119 vec![
21120 OsString::from("--plugin-dir"),
21121 std::env::current_dir().unwrap().into_os_string(),
21122 ],
21123 )
21124 .unwrap();
21125 let instance_overlay = shared
21126 .install_prepared_launch_overlay(
21127 address.session.instance_id,
21128 PreparedNativeLaunchOverlay::Instance(overlay),
21129 )
21130 .unwrap()
21131 .unwrap();
21132 let bundle = ResolvedBundleReceipt {
21133 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21134 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21135 digest: crate::protocol::SpawnBundleDigest::new(format!(
21136 "sha256:{}",
21137 "0".repeat(64),
21138 ))
21139 .unwrap(),
21140 };
21141 shared.bind_session_with_materialization(
21142 &address,
21143 ProviderRuntimePolicy::raw_pty(),
21144 Some(environment_profile),
21145 Some(bundle),
21146 None,
21147 None,
21148 );
21149 selection.retain();
21150 instance_overlay.retain();
21151
21152 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21153 assert!(matches!(
21154 runtime.remove_native_launch_profile(&native_id),
21155 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21156 ));
21157 assert!(shared.remove_binding(&address).is_some());
21158 assert!(!shared
21159 .native_launch_profile_control
21160 .as_ref()
21161 .unwrap()
21162 .clear_native_instance_launch_overlay(address.session.instance_id));
21163 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21164 }
21165
21166 #[test]
21167 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21168 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21169 let address = SessionAddress {
21170 workspace_id: WorkspaceId::new("primary").unwrap(),
21171 session: SessionKey {
21172 instance_id: AgentInstanceId(201),
21173 generation: SessionGeneration(1),
21174 },
21175 };
21176 let error = match shared.prepare_session_materialization(
21177 &address,
21178 &agent("claude"),
21179 SessionMode::Pty,
21180 Some(&receipt),
21181 None,
21182 None,
21183 None,
21184 ) {
21185 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21186 Err(error) => error,
21187 };
21188 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21189 assert!(shared.handle.snapshot().sessions.is_empty());
21190 assert!(shared
21191 .session_bindings
21192 .lock()
21193 .unwrap_or_else(|poisoned| poisoned.into_inner())
21194 .is_empty());
21195 assert!(shared
21196 .materializations
21197 .lock()
21198 .unwrap_or_else(|poisoned| poisoned.into_inner())
21199 .is_empty());
21200 drop(shared);
21201 drop(runtime);
21202 std::fs::remove_dir_all(root).unwrap();
21203 }
21204
21205 #[test]
21206 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21207 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21208 let address = SessionAddress {
21209 workspace_id: WorkspaceId::new("primary").unwrap(),
21210 session: SessionKey {
21211 instance_id: AgentInstanceId(202),
21212 generation: SessionGeneration(1),
21213 },
21214 };
21215 let (overlay, materialization_guard) = shared
21216 .prepare_session_materialization(
21217 &address,
21218 &agent("claude"),
21219 SessionMode::Pty,
21220 Some(&receipt),
21221 None,
21222 None,
21223 None,
21224 )
21225 .unwrap()
21226 .unwrap();
21227 let materialization_id = materialization_guard.id().unwrap().clone();
21228 let materialization_root = shared
21229 .materializations
21230 .lock()
21231 .unwrap_or_else(|poisoned| poisoned.into_inner())
21232 .get(&materialization_id)
21233 .unwrap()
21234 .root()
21235 .to_path_buf();
21236 let selection = shared
21237 .select_environment_profile(
21238 address.session.instance_id,
21239 &agent("claude"),
21240 SessionMode::Pty,
21241 Some(&receipt),
21242 )
21243 .unwrap()
21244 .unwrap();
21245 assert!(shared
21246 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21247 .unwrap()
21248 .is_none());
21249 shared.bind_session_with_materialization(
21250 &address,
21251 ProviderRuntimePolicy::raw_pty(),
21252 Some(receipt),
21253 None,
21254 None,
21255 Some(materialization_id),
21256 );
21257 selection.retain();
21258 materialization_guard.retain();
21259
21260 assert!(materialization_root.is_dir());
21261 assert!(shared
21262 .session_bindings
21263 .lock()
21264 .unwrap_or_else(|poisoned| poisoned.into_inner())
21265 .contains_key(&address.session.instance_id));
21266
21267 let removed = shared.remove_binding(&address).unwrap();
21268 shared
21269 .cleanup_session_owned_materialization(&address, &removed)
21270 .unwrap();
21271 assert!(!materialization_root.exists());
21272 assert!(shared
21273 .materializations
21274 .lock()
21275 .unwrap_or_else(|poisoned| poisoned.into_inner())
21276 .is_empty());
21277 drop(shared);
21278 drop(runtime);
21279 std::fs::remove_dir_all(root).unwrap();
21280 }
21281
21282 #[tokio::test]
21283 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21284 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21285 let address = SessionAddress {
21286 workspace_id: WorkspaceId::new("primary").unwrap(),
21287 session: SessionKey {
21288 instance_id: AgentInstanceId(203),
21289 generation: SessionGeneration(1),
21290 },
21291 };
21292 let (overlay, materialization_guard) = shared
21293 .prepare_session_materialization(
21294 &address,
21295 &agent("claude"),
21296 SessionMode::Pty,
21297 Some(&receipt),
21298 None,
21299 None,
21300 None,
21301 )
21302 .unwrap()
21303 .unwrap();
21304 let materialization_id = materialization_guard.id().unwrap().clone();
21305 let materialization_root = shared
21306 .materializations
21307 .lock()
21308 .unwrap_or_else(|poisoned| poisoned.into_inner())
21309 .get(&materialization_id)
21310 .unwrap()
21311 .root()
21312 .to_path_buf();
21313 let selection = shared
21314 .select_environment_profile(
21315 address.session.instance_id,
21316 &agent("claude"),
21317 SessionMode::Pty,
21318 Some(&receipt),
21319 )
21320 .unwrap()
21321 .unwrap();
21322 assert!(shared
21323 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21324 .unwrap()
21325 .is_none());
21326 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21327 let record_id = shared
21328 .bind_spawn_session_with_materialization(
21329 &address,
21330 agent("claude"),
21331 SessionMode::Pty,
21332 policy,
21333 SpawnRecordPolicy::ProviderIdentityOnly,
21334 Some(receipt.clone()),
21335 None,
21336 None,
21337 Some(materialization_id.clone()),
21338 )
21339 .unwrap()
21340 .unwrap();
21341 selection.retain();
21342 materialization_guard.retain();
21343 assert_eq!(
21344 shared
21345 .materializations
21346 .lock()
21347 .unwrap_or_else(|poisoned| poisoned.into_inner())
21348 .get(&materialization_id)
21349 .unwrap()
21350 .owner(),
21351 &MaterializationOwner::Record {
21352 record_id: record_id.clone(),
21353 },
21354 );
21355
21356 let removed = shared.remove_binding(&address).unwrap();
21357 shared
21358 .cleanup_session_owned_materialization(&address, &removed)
21359 .unwrap();
21360 assert!(materialization_root.is_dir());
21361 assert!(shared
21362 .resolve_record_materialization(
21363 &record_id,
21364 &agent("claude"),
21365 SessionMode::Pty,
21366 Some(&receipt),
21367 None,
21368 None,
21369 )
21370 .unwrap()
21371 .is_some());
21372 {
21373 let mut records = shared
21374 .session_records
21375 .lock()
21376 .unwrap_or_else(|poisoned| poisoned.into_inner());
21377 let record = records.records.get_mut(&record_id).unwrap();
21378 record.active_session = None;
21379 record.state = ManagedSessionState::Unavailable;
21380 }
21381 shared.forget_session_record(&record_id).await.unwrap();
21382 assert!(!materialization_root.exists());
21383 assert!(shared
21384 .materializations
21385 .lock()
21386 .unwrap_or_else(|poisoned| poisoned.into_inner())
21387 .is_empty());
21388 drop(shared);
21389 drop(runtime);
21390 std::fs::remove_dir_all(root).unwrap();
21391 }
21392
21393 #[test]
21394 fn record_materialization_revalidation_failure_persists_recovery_required() {
21395 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21396 let state_path = root.join("node-state-v6.json");
21397 shared.state_path = Some(state_path.clone());
21398 let (_address, _record_id, materialization_id, materialization_root) =
21399 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21400 std::fs::write(
21401 materialization_root.join(".gate4agent-materialization-owner"),
21402 b"tampered",
21403 )
21404 .unwrap();
21405
21406 let error = match shared.resolve_record_materialization(
21407 &_record_id,
21408 &agent("claude"),
21409 SessionMode::Pty,
21410 Some(&receipt),
21411 None,
21412 None,
21413 ) {
21414 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21415 Err(error) => error,
21416 };
21417 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21418 assert_eq!(
21419 shared
21420 .materializations
21421 .lock()
21422 .unwrap_or_else(|poisoned| poisoned.into_inner())
21423 .get(&materialization_id)
21424 .unwrap()
21425 .state(),
21426 MaterializationState::RecoveryRequired,
21427 );
21428 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21429 assert_eq!(loaded.materializations.len(), 1);
21430 assert_eq!(
21431 loaded.materializations[0].state(),
21432 MaterializationState::RecoveryRequired,
21433 );
21434 drop(shared);
21435 drop(runtime);
21436 std::fs::remove_dir_all(root).unwrap();
21437 }
21438
21439 #[test]
21440 fn transient_record_materialization_resolution_failure_remains_ready() {
21441 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21442 let (_address, record_id, materialization_id, _materialization_root) =
21443 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21444 deny.store(true, Ordering::Release);
21445
21446 let error = match shared.resolve_record_materialization(
21447 &record_id,
21448 &agent("claude"),
21449 SessionMode::Pty,
21450 Some(&receipt),
21451 None,
21452 None,
21453 ) {
21454 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21455 Err(error) => error,
21456 };
21457 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21458 assert_eq!(
21459 shared
21460 .materializations
21461 .lock()
21462 .unwrap_or_else(|poisoned| poisoned.into_inner())
21463 .get(&materialization_id)
21464 .unwrap()
21465 .state(),
21466 MaterializationState::Ready,
21467 );
21468 drop(shared);
21469 drop(runtime);
21470 std::fs::remove_dir_all(root).unwrap();
21471 }
21472
21473 #[test]
21474 fn startup_missing_record_materialization_is_not_resumable() {
21475 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21476 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21477 shared
21478 .insert_record(ManagedSessionRecord {
21479 record_id: record_id.clone(),
21480 display_name: "missing materialization".to_owned(),
21481 provider: agent("claude"),
21482 mode: SessionMode::Pty,
21483 state: ManagedSessionState::Dormant,
21484 workspace_id: WorkspaceId::new("primary").unwrap(),
21485 canonical_root: opaque_windows_path(
21486 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21487 ),
21488 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21489 key: gate4agent_types::ProviderSessionKey::SessionId,
21490 id: "provider-session-missing-materialization".to_owned(),
21491 transcript_path: None,
21492 }),
21493 active_session: None,
21494 environment_profile: Some(receipt),
21495 bundle: None,
21496 context_id: None,
21497 context: None,
21498 exported_context: None,
21499 task_binding: None,
21500 created_at_unix_ms: 1,
21501 updated_at_unix_ms: 1,
21502 last_error: None,
21503 })
21504 .unwrap();
21505
21506 shared.reconcile_materializations();
21507 let record = shared.record(&record_id).unwrap();
21508 assert_eq!(record.state, ManagedSessionState::Unavailable);
21509 assert!(record.provider_session.is_none());
21510 assert_eq!(
21511 record.last_error.as_deref(),
21512 Some("environment-profile-unavailable"),
21513 );
21514 drop(shared);
21515 drop(runtime);
21516 std::fs::remove_dir_all(root).unwrap();
21517 }
21518
21519 #[tokio::test]
21520 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21521 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21522 let lease = install_managed_lease(&shared);
21523 let address = SessionAddress {
21524 workspace_id: WorkspaceId::new("primary").unwrap(),
21525 session: SessionKey {
21526 instance_id: AgentInstanceId(204),
21527 generation: SessionGeneration(1),
21528 },
21529 };
21530 let (overlay, materialization_guard) = shared
21531 .prepare_session_materialization(
21532 &address,
21533 &agent("claude"),
21534 SessionMode::Pty,
21535 Some(&receipt),
21536 None,
21537 None,
21538 Some(lease.lease_id.clone()),
21539 )
21540 .unwrap()
21541 .unwrap();
21542 let materialization_id = materialization_guard.id().unwrap().clone();
21543 let materialization_root = shared
21544 .materializations
21545 .lock()
21546 .unwrap_or_else(|poisoned| poisoned.into_inner())
21547 .get(&materialization_id)
21548 .unwrap()
21549 .root()
21550 .to_path_buf();
21551 let selection = shared
21552 .select_environment_profile(
21553 address.session.instance_id,
21554 &agent("claude"),
21555 SessionMode::Pty,
21556 Some(&receipt),
21557 )
21558 .unwrap()
21559 .unwrap();
21560 assert!(shared
21561 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21562 .unwrap()
21563 .is_none());
21564 shared.bind_session_with_materialization(
21565 &address,
21566 ProviderRuntimePolicy::raw_pty(),
21567 Some(receipt),
21568 None,
21569 None,
21570 Some(materialization_id),
21571 );
21572 selection.retain();
21573 materialization_guard.retain();
21574
21575 let blocked = shared
21576 .cleanup_managed_worktree(&lease.lease_id, false)
21577 .await
21578 .unwrap_err();
21579 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21580 assert!(materialization_root.is_dir());
21581 let removed = shared.remove_binding(&address).unwrap();
21582 shared
21583 .cleanup_session_owned_materialization(&address, &removed)
21584 .unwrap();
21585 assert!(!materialization_root.exists());
21586 drop(shared);
21587 drop(runtime);
21588 std::fs::remove_dir_all(root).unwrap();
21589 }
21590
21591 #[tokio::test]
21592 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21593 let git_root = temporary_workspace_root("raw-managed-inventory");
21594 let source = git_root.join("source");
21595 let allocation = git_root.join("allocation");
21596 std::fs::create_dir_all(&source).unwrap();
21597 std::fs::create_dir_all(&allocation).unwrap();
21598 run_test_git(&source, &["init"]);
21599 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21600 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21601 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21602 run_test_git(&source, &["add", "seed.txt"]);
21603 run_test_git(&source, &["commit", "-m", "seed"]);
21604 let profile = ManagedWorktreeProfile::new(
21605 WorktreeProfileId::new("default").unwrap(),
21606 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21607 &allocation,
21608 "gate4agent",
21609 "HEAD",
21610 ManagedWorktreeRetention::RemoveWhenReleased,
21611 )
21612 .unwrap();
21613 let workspace = WorkspaceConfig::new(
21614 WorkspaceId::new("primary").unwrap(),
21615 &source,
21616 )
21617 .unwrap()
21618 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21619 .with_managed_worktree_profile(profile.clone())
21620 .unwrap();
21621 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21622 materialization_test_shared_with_workspace(false, workspace);
21623 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21624 let source_text = source.to_string_lossy().into_owned();
21625 let target_text = target.to_string_lossy().into_owned();
21626 let created = create_git_worktree(
21627 &source_text,
21628 &target_text,
21629 "gate4agent/mw-raw-inventory",
21630 Some("HEAD"),
21631 )
21632 .await
21633 .unwrap();
21634 let lease = ManagedWorktreeLeaseRecord {
21635 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21636 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21637 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21638 profile_id: profile.profile_id().clone(),
21639 profile_revision: profile.revision().clone(),
21640 target_root: created.path.clone(),
21641 branch: "gate4agent/mw-raw-inventory".to_owned(),
21642 base_commit: created.head.clone(),
21643 expected_head: Some(created.head.clone()),
21644 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21645 state: ManagedWorktreeLeaseState::Ready,
21646 session_holders: Vec::new(),
21647 record_holders: Vec::new(),
21648 cleanup_failure: None,
21649 created_at_unix_ms: 1,
21650 updated_at_unix_ms: 1,
21651 };
21652 shared
21653 .workspaces
21654 .write()
21655 .unwrap_or_else(|poisoned| poisoned.into_inner())
21656 .insert(lease.workspace_id.clone(), created.path.clone());
21657 *shared
21658 .managed_worktrees
21659 .lock()
21660 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21661 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21662 let address = SessionAddress {
21663 workspace_id: lease.workspace_id.clone(),
21664 session: SessionKey {
21665 instance_id: AgentInstanceId(205),
21666 generation: SessionGeneration(1),
21667 },
21668 };
21669 let (overlay, materialization_guard) = shared
21670 .prepare_session_materialization(
21671 &address,
21672 &agent("claude"),
21673 SessionMode::Pty,
21674 Some(&receipt),
21675 None,
21676 None,
21677 Some(lease.lease_id.clone()),
21678 )
21679 .unwrap()
21680 .unwrap();
21681 let materialization_id = materialization_guard.id().unwrap().clone();
21682 let materialization_root = shared
21683 .materializations
21684 .lock()
21685 .unwrap_or_else(|poisoned| poisoned.into_inner())
21686 .get(&materialization_id)
21687 .unwrap()
21688 .root()
21689 .to_path_buf();
21690 let selection = shared
21691 .select_environment_profile(
21692 address.session.instance_id,
21693 &agent("claude"),
21694 SessionMode::Pty,
21695 Some(&receipt),
21696 )
21697 .unwrap()
21698 .unwrap();
21699 assert!(shared
21700 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21701 .unwrap()
21702 .is_none());
21703 let record_id = shared
21704 .bind_spawn_session_with_materialization(
21705 &address,
21706 agent("claude"),
21707 SessionMode::Pty,
21708 ProviderRuntimePolicy::raw_pty(),
21709 SpawnRecordPolicy::Always,
21710 Some(receipt.clone()),
21711 None,
21712 None,
21713 Some(materialization_id.clone()),
21714 )
21715 .unwrap()
21716 .unwrap();
21717 selection.retain();
21718 materialization_guard.retain();
21719 let record = shared.record(&record_id).unwrap();
21720 assert_eq!(record.state, ManagedSessionState::Live);
21721 assert!(record.provider_session.is_none());
21722 assert!(record.environment_profile.is_none());
21723 assert!(record.bundle.is_none());
21724 assert!(record.context_id.is_none());
21725 assert!(record.context.is_none());
21726 assert_eq!(record.active_session.as_ref(), Some(&address));
21727 assert_eq!(
21728 shared
21729 .materializations
21730 .lock()
21731 .unwrap_or_else(|poisoned| poisoned.into_inner())
21732 .get(&materialization_id)
21733 .unwrap()
21734 .owner(),
21735 &MaterializationOwner::Session {
21736 incarnation_id: shared.incarnation_id,
21737 instance_id: address.session.instance_id,
21738 generation: address.session.generation,
21739 },
21740 );
21741 {
21742 let mut bindings = shared
21743 .session_bindings
21744 .lock()
21745 .unwrap_or_else(|poisoned| poisoned.into_inner());
21746 bindings
21747 .get_mut(&address.session.instance_id)
21748 .unwrap()
21749 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21750 }
21751 let lease_snapshot = shared
21752 .managed_worktrees
21753 .lock()
21754 .unwrap_or_else(|poisoned| poisoned.into_inner())
21755 .bind_session(
21756 &lease.lease_id,
21757 ManagedWorktreeSessionHolder {
21758 incarnation_id: shared.incarnation_id,
21759 instance_id: address.session.instance_id,
21760 generation: address.session.generation,
21761 },
21762 None,
21763 unix_time_ms(),
21764 )
21765 .unwrap();
21766 assert_eq!(lease_snapshot.active_session_count, 1);
21767 assert_eq!(lease_snapshot.managed_record_count, 0);
21768 shared.persist_state().unwrap();
21769 assert!(shared
21770 .managed_worktrees
21771 .lock()
21772 .unwrap_or_else(|poisoned| poisoned.into_inner())
21773 .get(&lease.lease_id)
21774 .unwrap()
21775 .record_holders
21776 .is_empty());
21777 shared
21778 .handle
21779 .dispatch(shared.prepare_command(ControlCommand::Register {
21780 instance_id: address.session.instance_id,
21781 agent_id: agent("claude"),
21782 transport: TransportKind::Pty,
21783 }))
21784 .unwrap();
21785 runtime.tick().await;
21786 shared.reconcile_managed_record(
21787 &record_id,
21788 &address,
21789 &ControlEvent {
21790 sequence: 1,
21791 command_id: None,
21792 instance_id: address.session.instance_id,
21793 generation: address.session.generation,
21794 event: ControlEventKind::Removed,
21795 },
21796 false,
21797 false,
21798 );
21799 let remove = shared.remove_session(&address);
21800 let drive_runtime = async {
21801 runtime.tick().await;
21802 };
21803 let (removed, ()) = tokio::join!(remove, drive_runtime);
21804 removed.unwrap();
21805
21806 assert!(!materialization_root.exists());
21807 assert!(!Path::new(&created.path).exists());
21808 assert_eq!(
21809 shared
21810 .managed_worktrees
21811 .lock()
21812 .unwrap_or_else(|poisoned| poisoned.into_inner())
21813 .get(&lease.lease_id)
21814 .unwrap()
21815 .state,
21816 ManagedWorktreeLeaseState::Removed,
21817 );
21818 let renamed = shared
21819 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21820 .unwrap();
21821 assert_eq!(renamed.record_id, record_id);
21822 assert_eq!(renamed.display_name, "raw managed inventory");
21823 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21824 assert!(renamed.active_session.is_none());
21825
21826 drop(shared);
21827 drop(runtime);
21828 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21829 std::fs::remove_dir_all(git_root).unwrap();
21830 }
21831
21832 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21833 let root = std::env::current_dir().unwrap()
21834 .join("managed-test-target")
21835 .to_string_lossy()
21836 .into_owned();
21837 let lease = ManagedWorktreeLeaseRecord {
21838 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21839 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21840 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21841 profile_id: WorktreeProfileId::new("default").unwrap(),
21842 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21843 target_root: root.clone(),
21844 branch: "gate4agent/mw-test".to_owned(),
21845 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21846 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21847 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21848 state: ManagedWorktreeLeaseState::Ready,
21849 session_holders: Vec::new(),
21850 record_holders: Vec::new(),
21851 cleanup_failure: None,
21852 created_at_unix_ms: 1,
21853 updated_at_unix_ms: 1,
21854 };
21855 shared.workspaces.write()
21856 .unwrap_or_else(|poisoned| poisoned.into_inner())
21857 .insert(lease.workspace_id.clone(), root);
21858 *shared.managed_worktrees.lock()
21859 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21860 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21861 lease
21862 }
21863
21864 #[cfg(feature = "fixture")]
21865 #[test]
21866 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21867 let mut shared = terminal_test_shared();
21868 shared.fixture_semantic_hook_policy = true;
21869 let lease = install_managed_lease(&shared);
21870 let address = SessionAddress {
21871 workspace_id: lease.workspace_id.clone(),
21872 session: SessionKey {
21873 instance_id: AgentInstanceId(206),
21874 generation: SessionGeneration(1),
21875 },
21876 };
21877 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21878 let effective_runtime_policy =
21879 shared.effective_spawn_runtime_policy(pre_admission_policy);
21880 assert_ne!(effective_runtime_policy, pre_admission_policy);
21881 assert!(effective_runtime_policy.provider_session_identity);
21882
21883 let record_id = shared
21884 .bind_spawn_session(
21885 &address,
21886 agent("claude"),
21887 SessionMode::Pty,
21888 effective_runtime_policy,
21889 SpawnRecordPolicy::Always,
21890 None,
21891 )
21892 .unwrap()
21893 .unwrap();
21894 assert_eq!(
21895 shared.record(&record_id).unwrap().state,
21896 ManagedSessionState::IdentityPending,
21897 );
21898 let (record_holder, raw_inventory_record_id) = {
21899 let mut bindings = shared
21900 .session_bindings
21901 .lock()
21902 .unwrap_or_else(|poisoned| poisoned.into_inner());
21903 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
21904 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
21905 managed_spawn_record_ownership(binding, effective_runtime_policy)
21906 };
21907 assert_eq!(record_holder.as_ref(), Some(&record_id));
21908 assert!(raw_inventory_record_id.is_none());
21909 let snapshot = shared
21910 .managed_worktrees
21911 .lock()
21912 .unwrap_or_else(|poisoned| poisoned.into_inner())
21913 .bind_session(
21914 &lease.lease_id,
21915 ManagedWorktreeSessionHolder {
21916 incarnation_id: shared.incarnation_id,
21917 instance_id: address.session.instance_id,
21918 generation: address.session.generation,
21919 },
21920 record_holder,
21921 unix_time_ms(),
21922 )
21923 .unwrap();
21924 assert_eq!(snapshot.managed_record_count, 1);
21925 assert_eq!(
21926 shared
21927 .managed_worktrees
21928 .lock()
21929 .unwrap_or_else(|poisoned| poisoned.into_inner())
21930 .get(&lease.lease_id)
21931 .unwrap()
21932 .record_holders,
21933 vec![record_id.clone()],
21934 );
21935
21936 let identity = ProviderSessionIdentity {
21937 key: ProviderSessionKey::SessionId,
21938 id: "monitoring-fixture-managed-session".to_owned(),
21939 transcript_path: None,
21940 };
21941 shared.publish_control(ControlEvent {
21942 sequence: 1,
21943 command_id: None,
21944 instance_id: address.session.instance_id,
21945 generation: address.session.generation,
21946 event: ControlEventKind::ProviderEvent {
21947 sequence: 1,
21948 source: gate4agent_types::ProviderSource {
21949 family: AdapterFamily::Hook,
21950 binding: AdapterBinding::new(
21951 gate4agent_types::AdapterId::new("claude-code").unwrap(),
21952 "fixture/v1",
21953 gate4agent_types::AdapterVerification::SyntheticFixture,
21954 )
21955 .unwrap(),
21956 },
21957 source_sequence: 1,
21958 event: ProviderEvent::SessionIdentityObserved {
21959 identity: identity.clone(),
21960 },
21961 },
21962 });
21963
21964 let live = shared.record(&record_id).unwrap();
21965 assert_eq!(live.state, ManagedSessionState::Live);
21966 assert_eq!(live.provider_session.as_ref(), Some(&identity));
21967 assert_eq!(live.active_session.as_ref(), Some(&address));
21968 assert_eq!(
21969 shared
21970 .managed_worktrees
21971 .lock()
21972 .unwrap_or_else(|poisoned| poisoned.into_inner())
21973 .get(&lease.lease_id)
21974 .unwrap()
21975 .record_holders,
21976 vec![record_id],
21977 );
21978 }
21979
21980 fn managed_v2_test_shared(
21981 allocation_root: &Path,
21982 state_path: Option<PathBuf>,
21983 profile_revision: &str,
21984 ) -> NodeShared {
21985 let profile = ManagedWorktreeProfile::new(
21986 WorktreeProfileId::new("default").unwrap(),
21987 WorktreeProfileRevision::new(profile_revision).unwrap(),
21988 allocation_root,
21989 "gate4agent",
21990 "HEAD",
21991 ManagedWorktreeRetention::RemoveWhenReleased,
21992 )
21993 .unwrap();
21994 let workspace = WorkspaceConfig::new(
21995 WorkspaceId::new("primary").unwrap(),
21996 std::env::current_dir().unwrap(),
21997 )
21998 .unwrap()
21999 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22000 .with_managed_worktree_profile(profile)
22001 .unwrap();
22002 let catalog = active_registry().unwrap();
22003 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22004 let mut shared = NodeShared::new(
22005 handle,
22006 "fixture-token".to_owned(),
22007 NodeId::new("node-terminal-test").unwrap(),
22008 vec![workspace],
22009 vec![agent("claude")],
22010 );
22011 shared.state_path = state_path;
22012 shared
22013 }
22014
22015 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22016 let mut spawn_spec = spawn_spec_fixture();
22017 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22018 ManagedWorktreeSpawnRequestV2 {
22019 spawn_spec,
22020 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22021 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22022 }
22023 }
22024
22025 #[test]
22026 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22027 let shared = terminal_test_shared();
22028 let mut lease = install_managed_lease(&shared);
22029 let session = SessionAddress {
22030 workspace_id: lease.workspace_id.clone(),
22031 session: SessionKey {
22032 instance_id: AgentInstanceId(73),
22033 generation: SessionGeneration(4),
22034 },
22035 };
22036 lease.state = ManagedWorktreeLeaseState::InUse;
22037 lease.session_holders.push(ManagedWorktreeSessionHolder {
22038 incarnation_id: shared.incarnation_id,
22039 instance_id: session.session.instance_id,
22040 generation: session.session.generation,
22041 });
22042 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22043 assert!(resolved.target.worktree_id.is_none());
22044
22045 let receipt = managed_spawn_receipt(
22046 &resolved,
22047 shared.incarnation_id,
22048 session.clone(),
22049 lease.snapshot(),
22050 None,
22051 None,
22052 None,
22053 );
22054 assert!(resolved.target.worktree_id.is_none());
22055 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22056 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22057 assert_eq!(receipt.spawn.session, session);
22058 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22059 let encoded = serde_json::to_vec(&receipt).unwrap();
22060 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22061 assert_eq!(decoded, receipt);
22062 }
22063
22064 #[tokio::test]
22065 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22066 let root = temporary_workspace_root("managed-v2-revision");
22067 let allocation = root.join("allocation");
22068 std::fs::create_dir_all(&allocation).unwrap();
22069 let shared = managed_v2_test_shared(
22070 &allocation,
22071 Some(root.join("node-state.json")),
22072 "v1",
22073 );
22074 let mut request = managed_v2_request("managed-v2-revision");
22075 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22076
22077 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22078
22079 assert_eq!(
22080 failure.code,
22081 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22082 );
22083 assert!(shared
22084 .managed_worktrees
22085 .lock()
22086 .unwrap_or_else(|poisoned| poisoned.into_inner())
22087 .records()
22088 .is_empty());
22089 assert!(shared
22090 .managed_spawn_replays
22091 .lock()
22092 .unwrap_or_else(|poisoned| poisoned.into_inner())
22093 .is_empty());
22094 std::fs::remove_dir_all(root).unwrap();
22095 }
22096
22097 #[test]
22098 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22099 let root = temporary_workspace_root("managed-v2-concurrent");
22100 let allocation = root.join("allocation");
22101 std::fs::create_dir_all(&allocation).unwrap();
22102 let shared = Arc::new(managed_v2_test_shared(
22103 &allocation,
22104 Some(root.join("node-state.json")),
22105 "v1",
22106 ));
22107 let request = managed_v2_request("managed-v2-concurrent");
22108 let barrier = Arc::new(std::sync::Barrier::new(3));
22109 let mut joins = Vec::new();
22110 for _ in 0..2 {
22111 let shared = Arc::clone(&shared);
22112 let request = request.clone();
22113 let barrier = Arc::clone(&barrier);
22114 joins.push(std::thread::spawn(move || {
22115 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22116 barrier.wait();
22117 shared.acquire_durable_managed_spawn_v2(&request, digest)
22118 }));
22119 }
22120 barrier.wait();
22121 let results = joins
22122 .into_iter()
22123 .map(|join| join.join().unwrap())
22124 .collect::<Vec<_>>();
22125 assert_eq!(
22126 results
22127 .iter()
22128 .filter(|result| {
22129 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22130 })
22131 .count(),
22132 1,
22133 );
22134 assert_eq!(
22135 results
22136 .iter()
22137 .filter(|result| {
22138 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22139 })
22140 .count(),
22141 1,
22142 );
22143 assert_eq!(
22144 shared
22145 .managed_spawn_replays
22146 .lock()
22147 .unwrap_or_else(|poisoned| poisoned.into_inner())
22148 .len(),
22149 1,
22150 );
22151 assert!(shared
22152 .managed_worktrees
22153 .lock()
22154 .unwrap_or_else(|poisoned| poisoned.into_inner())
22155 .records()
22156 .is_empty());
22157 drop(shared);
22158 std::fs::remove_dir_all(root).unwrap();
22159 }
22160
22161 #[tokio::test]
22162 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22163 let root = temporary_workspace_root("managed-v2-linked-pending");
22164 let allocation = root.join("allocation");
22165 std::fs::create_dir_all(&allocation).unwrap();
22166 let state_path = root.join("node-state.json");
22167 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22168 let lease = install_managed_lease(&shared);
22169 let request = managed_v2_request("managed-v2-linked-pending");
22170 assert!(matches!(
22171 shared
22172 .acquire_durable_managed_spawn_v2(
22173 &request,
22174 managed_spawn_request_digest_v2(&request).unwrap(),
22175 )
22176 .unwrap(),
22177 DurableManagedSpawnReplayDecision::Reserved,
22178 ));
22179 {
22180 let _transaction = shared
22181 .state_transaction
22182 .lock()
22183 .unwrap_or_else(|poisoned| poisoned.into_inner());
22184 shared
22185 .link_durable_managed_spawn_v2_lease_locked(
22186 &request.spawn_spec.idempotency_key,
22187 &lease.lease_id,
22188 )
22189 .unwrap();
22190 shared.persist_state_locked().unwrap();
22191 }
22192 drop(shared);
22193
22194 let mut loaded = session_registry::load(
22195 Some(&state_path),
22196 &NodeId::new("node-terminal-test").unwrap(),
22197 )
22198 .unwrap();
22199 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22200 *reopened
22201 .managed_worktrees
22202 .lock()
22203 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22204 ManagedWorktreeRegistry::from_records(
22205 std::mem::take(&mut loaded.managed_worktrees),
22206 std::mem::take(&mut loaded.managed_worktree_tombstones),
22207 )
22208 .unwrap();
22209 *reopened
22210 .managed_spawn_replays
22211 .lock()
22212 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22213 .managed_spawn_replays
22214 .into_iter()
22215 .map(|record| (record.idempotency_key.clone(), record))
22216 .collect();
22217 let lease_count = reopened
22218 .managed_worktrees
22219 .lock()
22220 .unwrap_or_else(|poisoned| poisoned.into_inner())
22221 .records()
22222 .len();
22223
22224 let failure = reopened
22225 .spawn_managed_worktree_v2(request)
22226 .await
22227 .unwrap_err();
22228
22229 assert_eq!(
22230 failure.code,
22231 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22232 );
22233 assert_eq!(
22234 reopened
22235 .managed_worktrees
22236 .lock()
22237 .unwrap_or_else(|poisoned| poisoned.into_inner())
22238 .records()
22239 .len(),
22240 lease_count,
22241 );
22242 std::fs::remove_dir_all(root).unwrap();
22243 }
22244
22245 #[tokio::test]
22246 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22247 let root = temporary_workspace_root("managed-v2-reopen");
22248 let allocation = root.join("allocation");
22249 std::fs::create_dir_all(&allocation).unwrap();
22250 let state_path = root.join("node-state.json");
22251 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22252 let lease = install_managed_lease(&shared);
22253 let request = managed_v2_request("managed-v2-reopen");
22254 let session = SessionAddress {
22255 workspace_id: lease.workspace_id.clone(),
22256 session: SessionKey {
22257 instance_id: AgentInstanceId(91),
22258 generation: SessionGeneration(1),
22259 },
22260 };
22261 let snapshot = {
22262 let mut registry = shared
22263 .managed_worktrees
22264 .lock()
22265 .unwrap_or_else(|poisoned| poisoned.into_inner());
22266 let current = registry.get_mut(&lease.lease_id).unwrap();
22267 current.state = ManagedWorktreeLeaseState::InUse;
22268 current.session_holders.push(ManagedWorktreeSessionHolder {
22269 incarnation_id: shared.incarnation_id,
22270 instance_id: session.session.instance_id,
22271 generation: session.session.generation,
22272 });
22273 current.snapshot()
22274 };
22275 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22276 let receipt = managed_spawn_receipt(
22277 &resolved,
22278 shared.incarnation_id,
22279 session,
22280 snapshot,
22281 None,
22282 None,
22283 None,
22284 );
22285 assert!(matches!(
22286 shared
22287 .acquire_durable_managed_spawn_v2(
22288 &request,
22289 managed_spawn_request_digest_v2(&request).unwrap(),
22290 )
22291 .unwrap(),
22292 DurableManagedSpawnReplayDecision::Reserved,
22293 ));
22294 {
22295 let _transaction = shared
22296 .state_transaction
22297 .lock()
22298 .unwrap_or_else(|poisoned| poisoned.into_inner());
22299 shared
22300 .link_durable_managed_spawn_v2_lease_locked(
22301 &request.spawn_spec.idempotency_key,
22302 &lease.lease_id,
22303 )
22304 .unwrap();
22305 shared.persist_state_locked().unwrap();
22306 }
22307 shared
22308 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22309 .unwrap();
22310 let legacy_same_key = ManagedWorktreeSpawnRequest {
22311 spawn_spec: request.spawn_spec.clone(),
22312 worktree_profile_id: request.worktree_profile_id.clone(),
22313 };
22314 shared.remember_managed_spawn_attempt(
22315 Some(&request.spawn_spec.idempotency_key),
22316 legacy_same_key.clone(),
22317 Ok(receipt.clone()),
22318 Instant::now(),
22319 );
22320 assert!(shared
22321 .replay_managed_spawn(&legacy_same_key, Instant::now())
22322 .unwrap()
22323 .is_none());
22324 drop(shared);
22325
22326 let mut loaded = session_registry::load(
22327 Some(&state_path),
22328 &NodeId::new("node-terminal-test").unwrap(),
22329 )
22330 .unwrap();
22331 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22332 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22333 *reopened
22334 .managed_worktrees
22335 .lock()
22336 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22337 ManagedWorktreeRegistry::from_records(
22338 std::mem::take(&mut loaded.managed_worktrees),
22339 std::mem::take(&mut loaded.managed_worktree_tombstones),
22340 )
22341 .unwrap();
22342 *reopened
22343 .managed_spawn_replays
22344 .lock()
22345 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22346 .managed_spawn_replays
22347 .into_iter()
22348 .map(|record| (record.idempotency_key.clone(), record))
22349 .collect();
22350 let lease_count_before = reopened
22351 .managed_worktrees
22352 .lock()
22353 .unwrap_or_else(|poisoned| poisoned.into_inner())
22354 .records()
22355 .len();
22356
22357 assert_eq!(
22358 reopened
22359 .spawn_managed_worktree_v2(request.clone())
22360 .await
22361 .unwrap(),
22362 receipt,
22363 );
22364 assert_eq!(
22365 reopened
22366 .managed_worktrees
22367 .lock()
22368 .unwrap_or_else(|poisoned| poisoned.into_inner())
22369 .records()
22370 .len(),
22371 lease_count_before,
22372 );
22373 let mut changed = request;
22374 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22375 let failure = reopened
22376 .spawn_managed_worktree_v2(changed)
22377 .await
22378 .unwrap_err();
22379 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22380 assert_eq!(
22381 reopened
22382 .managed_worktrees
22383 .lock()
22384 .unwrap_or_else(|poisoned| poisoned.into_inner())
22385 .records()
22386 .len(),
22387 lease_count_before,
22388 );
22389 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22390 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22391 let failure = reopened
22392 .spawn_managed_worktree_v2(new_key)
22393 .await
22394 .unwrap_err();
22395 assert_eq!(
22396 failure.code,
22397 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22398 );
22399 assert_eq!(
22400 reopened
22401 .managed_worktrees
22402 .lock()
22403 .unwrap_or_else(|poisoned| poisoned.into_inner())
22404 .records()
22405 .len(),
22406 lease_count_before,
22407 );
22408 assert_eq!(
22409 reopened
22410 .managed_spawn_replays
22411 .lock()
22412 .unwrap_or_else(|poisoned| poisoned.into_inner())
22413 .len(),
22414 1,
22415 );
22416 std::fs::remove_dir_all(root).unwrap();
22417 }
22418
22419 #[test]
22420 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22421 let shared = terminal_test_shared();
22422 let request = ManagedWorktreeSpawnRequest {
22423 spawn_spec: spawn_spec_fixture(),
22424 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22425 };
22426 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22427 shared.remember_managed_spawn(
22428 request.clone(),
22429 Err(expected.clone()),
22430 Instant::now(),
22431 );
22432
22433 assert_eq!(
22434 shared
22435 .replay_managed_spawn(&request, Instant::now())
22436 .unwrap()
22437 .unwrap(),
22438 Err(expected),
22439 );
22440 assert!(shared
22441 .managed_spawn_replays
22442 .lock()
22443 .unwrap_or_else(|poisoned| poisoned.into_inner())
22444 .is_empty());
22445 }
22446
22447 #[tokio::test]
22448 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22449 let shared = terminal_test_shared();
22450 let lease = install_managed_lease(&shared);
22451 let raw = shared.spawn_session(
22452 lease.workspace_id.clone(),
22453 agent("claude"),
22454 SessionMode::Pty,
22455 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22456 None,
22457 ).await.unwrap_err();
22458 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22459 let mut spec = spawn_spec_fixture();
22460 spec.target.workspace_id = lease.workspace_id.clone();
22461 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22462 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22463 let mut selected = spawn_spec_fixture();
22464 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22465 selected.target.worktree_id = Some(lease.workspace_id);
22466 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22467 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22468 assert!(shared.handle.snapshot().sessions.is_empty());
22469 }
22470
22471 #[test]
22472 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22473 let shared = terminal_test_shared();
22474 let lease = install_managed_lease(&shared);
22475 let snapshot = snapshot_for_wire(
22476 &shared, true, true, true, false, true, true, false, false, true,
22477 );
22478 assert!(snapshot.managed_worktrees.is_empty());
22479 assert!(snapshot.workspaces.iter().all(|workspace| {
22480 workspace.managed_worktree_profiles.is_none()
22481 }));
22482 let mut reply = ResponseEnvelope {
22483 request_id: 1,
22484 result: Ok(NodeResponse::Resync {
22485 event_sequence: 1,
22486 oldest_available_sequence: 1,
22487 snapshot: shared.snapshot(),
22488 events: vec![NodeEventEnvelope {
22489 sequence: 1,
22490 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22491 }],
22492 }),
22493 };
22494 project_response_without_managed_worktrees(&mut reply);
22495 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22496 panic!("projection changed the response kind");
22497 };
22498 assert!(snapshot.managed_worktrees.is_empty());
22499 assert!(events.is_empty());
22500 let mut git = git_snapshot_for_parser();
22501 git.branch = Some(lease.branch.clone());
22502 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22503 lease_id: lease.lease_id.clone(),
22504 source_workspace_id: lease.source_workspace_id.clone(),
22505 branch: lease.branch.clone(),
22506 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22507 active_session_count: 1,
22508 managed_record_count: 0,
22509 });
22510 let mut inspection_reply = ResponseEnvelope {
22511 request_id: 2,
22512 result: Ok(NodeResponse::WorkspaceInspected {
22513 inspection: WorkspaceInspection {
22514 workspace_id: lease.workspace_id.clone(),
22515 entries: Vec::new(),
22516 tree_truncated: false,
22517 git,
22518 truncation: None,
22519 },
22520 }),
22521 };
22522 project_response_without_managed_worktrees(&mut inspection_reply);
22523 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22524 panic!("projection changed the inspection response kind");
22525 };
22526 assert!(inspection.git.managed_worktree.is_none());
22527 let failure = managed_git_worktree_failure(GitWorktreeError {
22528 kind: GitWorktreeErrorKind::Failed,
22529 message: r"git failed at C:\private\managed-target".to_owned(),
22530 });
22531 assert!(!failure.message.contains("private"));
22532 assert!(!failure.message.contains("managed-target"));
22533 }
22534
22535 #[tokio::test]
22536 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22537 let root = temporary_workspace_root("managed-restart");
22538 let source = root.join("source");
22539 let allocation = root.join("allocation");
22540 std::fs::create_dir_all(&source).unwrap();
22541 std::fs::create_dir_all(&allocation).unwrap();
22542 run_test_git(&source, &["init"]);
22543 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22544 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22545 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22546 run_test_git(&source, &["add", "seed.txt"]);
22547 run_test_git(&source, &["commit", "-m", "seed"]);
22548 let profile = ManagedWorktreeProfile::new(
22549 WorktreeProfileId::new("default").unwrap(),
22550 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22551 &allocation,
22552 "gate4agent",
22553 "HEAD",
22554 ManagedWorktreeRetention::Retain,
22555 ).unwrap();
22556 let workspace = WorkspaceConfig::new(
22557 WorkspaceId::new("primary").unwrap(),
22558 &source,
22559 ).unwrap()
22560 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22561 .with_managed_worktree_profile(profile.clone()).unwrap();
22562 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22563 let source_text = workspace.canonical_root().to_owned();
22564 let target_text = target.to_string_lossy().into_owned();
22565 let created = create_git_worktree(
22566 &source_text,
22567 &target_text,
22568 "gate4agent/mw-restart",
22569 Some("HEAD"),
22570 ).await.unwrap();
22571 let lease = ManagedWorktreeLeaseRecord {
22572 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22573 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22574 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22575 profile_id: profile.profile_id().clone(),
22576 profile_revision: profile.revision().clone(),
22577 target_root: created.path.clone(),
22578 branch: "gate4agent/mw-restart".to_owned(),
22579 base_commit: created.head.clone(),
22580 expected_head: None,
22581 retention: ManagedWorktreeRetention::Retain,
22582 state: ManagedWorktreeLeaseState::Allocating,
22583 session_holders: Vec::new(),
22584 record_holders: Vec::new(),
22585 cleanup_failure: None,
22586 created_at_unix_ms: 1,
22587 updated_at_unix_ms: 1,
22588 };
22589 let catalog = active_registry().unwrap();
22590 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22591 let shared = NodeShared::new_with_incarnation(
22592 handle,
22593 "fixture-token".to_owned(),
22594 NodeId::new("node-managed-restart").unwrap(),
22595 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22596 vec![workspace],
22597 vec![agent("claude")],
22598 ProviderRuntimeStatuses::default(),
22599 None,
22600 Vec::new(),
22601 Vec::new(),
22602 SpawnProfileRegistry::default(),
22603 None,
22604 None,
22605 None,
22606 None,
22607 Vec::new(),
22608 vec![lease.clone()],
22609 Vec::new(),
22610 Vec::new(),
22611 None,
22612 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22613 );
22614 shared.reconcile_managed_worktrees().await;
22615 let retained = shared.managed_worktrees.lock()
22616 .unwrap_or_else(|poisoned| poisoned.into_inner())
22617 .get(&lease.lease_id).unwrap().clone();
22618 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22619 assert!(shared.workspaces.read()
22620 .unwrap_or_else(|poisoned| poisoned.into_inner())
22621 .contains_key(&lease.workspace_id));
22622 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22623 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22624 assert!(!Path::new(&created.path).exists());
22625 std::fs::remove_dir_all(root).unwrap();
22626 }
22627
22628 fn run_test_git(root: &Path, arguments: &[&str]) {
22629 let output = std::process::Command::new("git")
22630 .arg("-C").arg(root).args(arguments).output().unwrap();
22631 assert!(
22632 output.status.success(),
22633 "git {:?} failed: {}",
22634 arguments,
22635 String::from_utf8_lossy(&output.stderr),
22636 );
22637 }
22638
22639 #[tokio::test]
22640 async fn spawn_spec_validation_fails_before_session_mutation() {
22641 let shared = terminal_test_shared();
22642 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22643 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22644 }));
22645 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22646 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22647 }));
22648 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22649 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22650 }));
22651
22652 let mut target_mismatch = spawn_spec_fixture();
22653 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22654 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22655 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22656
22657 let mut unknown_profile = spawn_spec_fixture();
22658 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22659 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22660 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22661 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22662
22663 let mut revision_mismatch = spawn_spec_fixture();
22664 revision_mismatch.idempotency_key =
22665 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22666 revision_mismatch.expected_profile_revision =
22667 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22668 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22669 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22670
22671 let mut unsupported_materializer = spawn_spec_fixture();
22672 unsupported_materializer.idempotency_key =
22673 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22674 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22675 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22676 };
22677 let failure = shared
22678 .spawn_from_spec(unsupported_materializer)
22679 .await
22680 .unwrap_err();
22681 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22682
22683 let failure = shared
22684 .spawn_session_with_deadline(
22685 WorkspaceId::new("primary").unwrap(),
22686 agent("claude"),
22687 SessionMode::Pty,
22688 gate4agent_types::TerminalSize {
22689 rows: 24,
22690 columns: 80,
22691 },
22692 None,
22693 None,
22694 None,
22695 None,
22696 None,
22697 None,
22698 SpawnRecordPolicy::ProviderIdentityOnly,
22699 Some(Instant::now()),
22700 &[],
22701 None,
22702 ApprovalLevel::default(),
22703 None,
22704 None,
22705 )
22706 .await
22707 .unwrap_err();
22708 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22709 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22710 assert!(shared.handle.snapshot().sessions.is_empty());
22711 }
22712
22713 #[test]
22714 fn worktree_selection_requires_its_separate_negotiated_capability() {
22715 let mut spec = spawn_spec_fixture();
22716 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22717 let request = NodeRequest::SpawnSpec { spec };
22718 let spawn_spec_capability =
22719 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22720 assert!(request_uses_unnegotiated_capability(
22721 &request,
22722 &[spawn_spec_capability.clone()],
22723 ));
22724 let worktree_capability =
22725 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22726 assert!(request_uses_unnegotiated_capability(
22727 &request,
22728 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22729 ));
22730 let profile_revision_capability =
22731 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22732 assert!(request_uses_unnegotiated_capability(
22733 &request,
22734 &[
22735 spawn_spec_capability.clone(),
22736 worktree_capability.clone(),
22737 profile_revision_capability.clone(),
22738 ],
22739 ));
22740 let bundle_materialization_capability =
22747 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22748 assert!(request_uses_unnegotiated_capability(
22749 &request,
22750 &[
22751 spawn_spec_capability.clone(),
22752 worktree_capability.clone(),
22753 profile_revision_capability.clone(),
22754 bundle_materialization_capability.clone(),
22755 ],
22756 ));
22757 let history_context_pack_capability =
22758 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22759 assert!(!request_uses_unnegotiated_capability(
22760 &request,
22761 &[
22762 spawn_spec_capability,
22763 worktree_capability,
22764 profile_revision_capability,
22765 bundle_materialization_capability,
22766 history_context_pack_capability,
22767 ],
22768 ));
22769 }
22770
22771 #[tokio::test]
22772 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22773 let mut shared = terminal_test_shared();
22774 let spec = spawn_spec_fixture();
22775 let receipt = shared
22776 .resolve_spawn_spec(&spec)
22777 .unwrap()
22778 .receipt(shared.incarnation_id, terminal_address(1));
22779 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22780
22781 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22782 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22783 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22784
22785 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22786
22787 let mut revision_conflicting = spec.clone();
22788 revision_conflicting.expected_profile_revision =
22789 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22790 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22791 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22792
22793 let mut stale_cache_miss = spec.clone();
22794 stale_cache_miss.idempotency_key =
22795 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22796 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22797 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22798
22799 let mut conflicting = spec;
22800 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22801 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22802 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22803 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22804 assert!(shared.handle.snapshot().sessions.is_empty());
22805 }
22806
22807 #[tokio::test]
22808 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22809 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22810 let mut spec = spawn_spec_fixture();
22811 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22812 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22813 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22814
22815 let failure = shared
22816 .create_worktree(
22817 WorkspaceId::new("primary").unwrap(),
22818 WorkspaceId::new("new-worktree").unwrap(),
22819 "not-an-absolute-path".to_owned(),
22820 "invalid branch".to_owned(),
22821 None,
22822 )
22823 .await
22824 .unwrap_err();
22825 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22826
22827 let failure = shared
22828 .remove_worktree(
22829 WorkspaceId::new("primary").unwrap(),
22830 "not-an-absolute-path".to_owned(),
22831 )
22832 .await
22833 .unwrap_err();
22834 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22835 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22836 assert!(shared.handle.snapshot().sessions.is_empty());
22837 }
22838
22839 #[tokio::test]
22840 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22841 let root = temporary_workspace_root("manual-worktree-compensation");
22842 let source = root.join("source");
22843 let target = root.join("target");
22844 let invalid_state_parent = root.join("state-parent-is-a-file");
22845 let invalid_state_path = invalid_state_parent.join("state.json");
22846 std::fs::create_dir_all(&source).unwrap();
22847 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22848 run_test_git(&source, &["init"]);
22849 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22850 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22851 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22852 run_test_git(&source, &["add", "seed.txt"]);
22853 run_test_git(&source, &["commit", "-m", "seed"]);
22854
22855 let catalog = active_registry().unwrap();
22856 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22857 let workspace = WorkspaceConfig::new(
22858 WorkspaceId::new("primary").unwrap(),
22859 &source,
22860 ).unwrap();
22861 let mut shared = NodeShared::new(
22862 handle,
22863 "fixture-token".to_owned(),
22864 NodeId::new("node-worktree-compensation").unwrap(),
22865 vec![workspace],
22866 vec![agent("claude")],
22867 );
22868 shared.state_path = Some(invalid_state_path);
22869
22870 let error = shared.create_worktree(
22871 WorkspaceId::new("primary").unwrap(),
22872 WorkspaceId::new("compensated").unwrap(),
22873 target.to_string_lossy().into_owned(),
22874 "gate4agent/compensated".to_owned(),
22875 Some("HEAD".to_owned()),
22876 ).await.unwrap_err();
22877
22878 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22879 assert!(!target.exists());
22880 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22881 .iter()
22882 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22883 std::fs::remove_dir_all(root).unwrap();
22884 }
22885
22886 #[test]
22887 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22888 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22889 NativeGitWorktreeSnapshot {
22890 path: path.to_owned(),
22891 head: "0123456789abcdef".to_owned(),
22892 branch: Some("refs/heads/test".to_owned()),
22893 is_bare,
22894 is_main,
22895 locked: false,
22896 lock_reason: None,
22897 prunable: false,
22898 prunable_reason: None,
22899 workspace_id: None,
22900 }
22901 }
22902
22903 let source = r"C:\repo";
22904 let selected = r"C:\repo-worktree";
22905 let listed = vec![
22906 worktree(source, true, false),
22907 worktree(selected, false, false),
22908 ];
22909 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
22910
22911 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
22912 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
22913
22914 let bare = vec![
22915 worktree(source, true, false),
22916 worktree(selected, false, true),
22917 ];
22918 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
22919 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
22920
22921 let foreign_failure =
22922 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
22923 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
22924 }
22925
22926 fn terminal_frame(sequence: u64) -> TerminalFrame {
22927 TerminalFrame {
22928 sequence,
22929 size: gate4agent_types::TerminalSize {
22930 rows: 24,
22931 columns: 80,
22932 },
22933 cursor_row: 2,
22934 cursor_column: 3,
22935 contents: format!("frame-{sequence}"),
22936 formatted: vec![1, 2, 3],
22937 scrollback_formatted: Vec::new(),
22938 alternate_screen: false,
22939 mouse_protocol_enabled: false,
22940 mouse_protocol_encoding: Default::default(),
22941 produced_at_unix_ms: 0,
22942 screen_state: gate4agent_types::PtyScreenState::default(),
22943 bracketed_paste: None,
22944 }
22945 }
22946
22947 #[test]
22948 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
22949 let shared = terminal_test_shared();
22950 let mut receiver = shared.terminal_event_tx.subscribe();
22951 let original = terminal_address(1);
22952
22953 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22954 let first = receiver.try_recv().unwrap();
22955 assert_eq!(first.len(), 1);
22956 assert!(matches!(
22957 &first[0].event,
22958 NodeEvent::TerminalFrame { address, frame }
22959 if address == &original && frame.sequence == 7
22960 ));
22961
22962 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
22963 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
22964 assert!(matches!(
22965 receiver.try_recv(),
22966 Err(broadcast::error::TryRecvError::Empty)
22967 ));
22968
22969 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
22970 let advanced = receiver.try_recv().unwrap();
22971 assert_eq!(advanced[0].sequence, 2);
22972
22973 let resumed = terminal_address(2);
22974 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
22975 let reset = receiver.try_recv().unwrap();
22976 assert_eq!(reset[0].sequence, 3);
22977 assert!(matches!(
22978 &reset[0].event,
22979 NodeEvent::TerminalFrame { address, frame }
22980 if address == &resumed && frame.sequence == 1
22981 ));
22982 shared.clear_terminal_frame_watermark(&resumed);
22983 assert!(!shared
22984 .terminal_frame_watermarks
22985 .lock()
22986 .unwrap_or_else(|poisoned| poisoned.into_inner())
22987 .contains_key(&resumed.session.instance_id));
22988 }
22989
22990 #[test]
22991 fn terminal_frame_batches_replace_without_entering_node_event_history() {
22992 let shared = terminal_test_shared();
22993 let mut receiver = shared.terminal_event_tx.subscribe();
22994 let address = terminal_address(1);
22995 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
22996
22997 for sequence in 1..=count {
22998 shared.publish_terminal_frame_candidates(vec![(
22999 address.clone(),
23000 terminal_frame(sequence),
23001 )]);
23002 }
23003
23004 let history = shared
23005 .history
23006 .lock()
23007 .unwrap_or_else(|poisoned| poisoned.into_inner());
23008 assert!(history.events.is_empty());
23009 assert_eq!(history.last_sequence, count);
23010 assert_eq!(history.replay_floor_sequence, 1);
23011 drop(history);
23012 let NodeResponse::Resync {
23013 event_sequence,
23014 oldest_available_sequence,
23015 events,
23016 ..
23017 } = shared.resync(0) else {
23018 unreachable!("resync helper returned another response")
23019 };
23020 assert_eq!(event_sequence, count);
23021 assert_eq!(oldest_available_sequence, 1);
23022 assert!(events.is_empty());
23023 assert!(matches!(
23024 resync_required_event(&shared).event,
23025 NodeEvent::ResyncRequired {
23026 oldest_available_sequence: 1,
23027 }
23028 ));
23029 assert!(matches!(
23030 receiver.try_recv(),
23031 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23032 ));
23033 let latest = receiver.try_recv().unwrap();
23034 assert_eq!(latest.len(), 1);
23035 assert_eq!(latest[0].sequence, count);
23036 }
23037
23038 #[test]
23039 fn real_durable_eviction_advances_authoritative_replay_floor() {
23040 let shared = terminal_test_shared();
23041 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23042 shared.publish(NodeEvent::ControllerChanged { controller: None });
23043 }
23044
23045 let NodeResponse::Resync {
23046 event_sequence,
23047 oldest_available_sequence,
23048 events,
23049 ..
23050 } = shared.resync(0) else {
23051 unreachable!("resync helper returned another response")
23052 };
23053 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23054 assert_eq!(oldest_available_sequence, 2);
23055 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23056 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23057 assert!(matches!(
23058 resync_required_event(&shared).event,
23059 NodeEvent::ResyncRequired {
23060 oldest_available_sequence: 2,
23061 }
23062 ));
23063 }
23064
23065 #[test]
23066 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23067 let shared = terminal_test_shared();
23068 let without_capability = terminal_event_subscription(&shared, false);
23069 let with_capability = terminal_event_subscription(&shared, true);
23070 assert!(without_capability.is_none());
23071 assert!(with_capability.is_some());
23072 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23073 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23074 }));
23075 }
23076
23077 #[test]
23078 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23079 let mut pending = Vec::new();
23080 let mut event_sequence = 0_u64;
23081 for frame_sequence in 1..=4 {
23082 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23083 event_sequence += 1;
23084 let mut address = terminal_address(1);
23085 address.session.instance_id = AgentInstanceId(instance);
23086 queue_connection_event(
23087 &mut pending,
23088 NodeEventEnvelope {
23089 sequence: event_sequence,
23090 event: NodeEvent::TerminalFrame {
23091 address,
23092 frame: terminal_frame(frame_sequence),
23093 },
23094 },
23095 0,
23096 );
23097 }
23098 }
23099 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23100 pending.sort_unstable_by_key(|event| event.sequence);
23101
23102 let mut uninterrupted = pending.clone();
23103 let first_burst = connection_event_burst_len(uninterrupted.len());
23104 uninterrupted.drain(..first_burst);
23105 let second_burst = connection_event_burst_len(uninterrupted.len());
23106 uninterrupted.drain(..second_burst);
23107 assert!(uninterrupted.is_empty());
23108
23109 let (frame_tx, mut frame_rx) =
23110 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23111 frame_tx
23112 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23113 request_id: 77,
23114 request: NodeRequest::Snapshot,
23115 })))
23116 .unwrap();
23117 let burst_len = connection_event_burst_len(pending.len());
23118 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23119 pending.drain(..burst_len);
23120 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23121 assert!(matches!(
23122 frame_rx.try_recv(),
23123 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23124 request_id: 77,
23125 request: NodeRequest::Snapshot,
23126 })))
23127 ));
23128 }
23129
23130 #[test]
23131 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23132 let mut pending = Vec::new();
23133 for sequence in [7_u64, 8] {
23134 let mut address = terminal_address(1);
23135 address.session.instance_id = AgentInstanceId(sequence);
23136 queue_connection_event(
23137 &mut pending,
23138 NodeEventEnvelope {
23139 sequence,
23140 event: NodeEvent::TerminalFrame {
23141 address,
23142 frame: terminal_frame(sequence),
23143 },
23144 },
23145 0,
23146 );
23147 }
23148 let mut discard_through = 0;
23149 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23150 assert!(pending.is_empty());
23151
23152 let replayed = NodeEventEnvelope {
23153 sequence: 8,
23154 event: NodeEvent::TerminalFrame {
23155 address: terminal_address(1),
23156 frame: terminal_frame(8),
23157 },
23158 };
23159 let advanced = NodeEventEnvelope {
23160 sequence: 9,
23161 event: NodeEvent::TerminalFrame {
23162 address: terminal_address(2),
23163 frame: terminal_frame(1),
23164 },
23165 };
23166 queue_connection_event_batch(
23167 &mut pending,
23168 &[replayed, advanced.clone()],
23169 discard_through,
23170 );
23171 assert_eq!(pending, vec![advanced]);
23172 }
23173
23174 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23175 ManagedSessionRecord {
23176 record_id: SessionRecordId::new(record_id).unwrap(),
23177 display_name: format!("{provider} record"),
23178 provider: agent(provider),
23179 mode: SessionMode::Pty,
23180 state: ManagedSessionState::Unavailable,
23181 workspace_id: WorkspaceId::new("primary").unwrap(),
23182 canonical_root: opaque_windows_path(
23183 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23184 ),
23185 provider_session: None,
23186 active_session: None,
23187 environment_profile: None,
23188 bundle: None,
23189 context_id: None,
23190 context: None,
23191 exported_context: None,
23192 task_binding: None,
23193 created_at_unix_ms: 1,
23194 updated_at_unix_ms: 1,
23195 last_error: None,
23196 }
23197 }
23198
23199 fn retention_test_record(
23200 workspace_id: &str,
23201 record_id: &str,
23202 state: ManagedSessionState,
23203 updated_at_unix_ms: u64,
23204 ) -> ManagedSessionRecord {
23205 ManagedSessionRecord {
23206 record_id: SessionRecordId::new(record_id).unwrap(),
23207 display_name: format!("{record_id} display"),
23208 provider: agent("claude"),
23209 mode: SessionMode::Pty,
23210 state,
23211 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23212 canonical_root: opaque_windows_path(
23213 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23214 ),
23215 provider_session: None,
23216 active_session: None,
23217 environment_profile: None,
23218 bundle: None,
23219 context_id: None,
23220 context: None,
23221 exported_context: None,
23222 task_binding: None,
23223 created_at_unix_ms: updated_at_unix_ms,
23224 updated_at_unix_ms,
23225 last_error: None,
23226 }
23227 }
23228
23229 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23237 vec![
23238 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23239 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23240 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23241 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23242 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23243 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23244 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23245 retention_test_record(
23246 "workspace-a",
23247 "decoy-pending",
23248 ManagedSessionState::IdentityPending,
23249 now - 500_000,
23250 ),
23251 retention_test_record(
23252 "workspace-a",
23253 "decoy-dormant",
23254 ManagedSessionState::Dormant,
23255 now - 500_000,
23256 ),
23257 ]
23258 }
23259
23260 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23261 let root = temporary_workspace_root("session-record-retention");
23262 std::fs::create_dir_all(&root).unwrap();
23263 let catalog = active_registry().unwrap();
23264 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23265 let workspace_a =
23266 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23267 let workspace_b =
23268 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23269 let mut shared = NodeShared::new(
23270 handle,
23271 "fixture-token".to_owned(),
23272 NodeId::new("node-retention-test").unwrap(),
23273 vec![workspace_a, workspace_b],
23274 vec![agent("claude")],
23275 );
23276 shared.state_path = Some(root.join("node-state-v10.json"));
23277 let now = unix_time_ms();
23278 for record in retention_fixture_records(now) {
23279 shared.insert_record(record).unwrap();
23280 }
23281 shared
23282 .persist_state_locked()
23283 .expect("fixture-only, empty-history persist against a fresh temp state path");
23284 (shared, runtime, root, now)
23285 }
23286
23287 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23288 shared
23289 .history
23290 .lock()
23291 .unwrap_or_else(|poisoned| poisoned.into_inner())
23292 .events
23293 .iter()
23294 .filter_map(|envelope| match &envelope.event {
23295 NodeEvent::SessionRecordRemoved { record_id } => {
23296 Some(record_id.as_str().to_owned())
23297 }
23298 _ => None,
23299 })
23300 .collect()
23301 }
23302
23303 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23304 shared
23305 .session_records
23306 .lock()
23307 .unwrap_or_else(|poisoned| poisoned.into_inner())
23308 .records
23309 .keys()
23310 .map(|record_id| record_id.as_str().to_owned())
23311 .collect()
23312 }
23313
23314 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23315 values.iter().map(|value| (*value).to_owned()).collect()
23316 }
23317
23318 #[tokio::test]
23319 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23320 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23321 shared.session_record_retention = SessionRecordRetentionConfig {
23322 age_ms: 0,
23323 keep_per_workspace: 1,
23324 };
23325 shared.retire_unavailable_session_records().await;
23326
23327 assert_eq!(
23328 retention_remaining_record_ids(&shared),
23329 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23330 );
23331 assert_eq!(
23332 retention_removed_record_ids(&shared),
23333 owned(&["a1", "a2", "b1", "b2"]),
23334 );
23335 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23336
23337 drop(shared);
23338 drop(runtime);
23339 std::fs::remove_dir_all(root).unwrap();
23340 }
23341
23342 #[tokio::test]
23348 async fn session_record_retention_both_set_retires_only_the_intersection() {
23349 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23350 shared.session_record_retention = SessionRecordRetentionConfig {
23351 age_ms: 250_000,
23352 keep_per_workspace: 1,
23353 };
23354 shared.retire_unavailable_session_records().await;
23355
23356 assert_eq!(
23357 retention_remaining_record_ids(&shared),
23358 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23359 );
23360 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23361 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23362
23363 drop(shared);
23364 drop(runtime);
23365 std::fs::remove_dir_all(root).unwrap();
23366 }
23367
23368 #[tokio::test]
23369 async fn session_record_retention_both_disabled_is_a_no_op() {
23370 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23371 shared.session_record_retention = SessionRecordRetentionConfig::default();
23372 shared.retire_unavailable_session_records().await;
23373
23374 assert_eq!(
23375 retention_remaining_record_ids(&shared),
23376 owned(&[
23377 "a1", "a2", "a3", "b1", "b2", "b3",
23378 "decoy-live", "decoy-pending", "decoy-dormant",
23379 ]),
23380 );
23381 assert!(retention_removed_record_ids(&shared).is_empty());
23382 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23383
23384 drop(shared);
23385 drop(runtime);
23386 std::fs::remove_dir_all(root).unwrap();
23387 }
23388
23389 #[test]
23390 fn session_record_context_export_selects_one_exact_history_identity() {
23391 let candidates = vec![
23392 HistoryCandidateSummary {
23393 id: "candidate-other".to_owned(),
23394 session_id_hint: "provider-other".to_owned(),
23395 modified_at_unix_ms: Some(1),
23396 },
23397 HistoryCandidateSummary {
23398 id: "candidate-exact".to_owned(),
23399 session_id_hint: "provider-exact".to_owned(),
23400 modified_at_unix_ms: Some(2),
23401 },
23402 ];
23403 assert_eq!(
23404 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23405 "candidate-exact",
23406 );
23407 }
23408
23409 #[test]
23410 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23411 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23412 id: id.to_owned(),
23413 session_id_hint: hint.to_owned(),
23414 modified_at_unix_ms: None,
23415 };
23416 let missing = select_history_candidate(
23417 &[candidate("candidate-other", "provider-other")],
23418 Some("provider-exact"),
23419 )
23420 .unwrap_err();
23421 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23422 let ambiguous = select_history_candidate(
23423 &[
23424 candidate("candidate-a", "provider-exact"),
23425 candidate("candidate-b", "provider-exact"),
23426 ],
23427 Some("provider-exact"),
23428 )
23429 .unwrap_err();
23430 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23431 }
23432
23433 #[test]
23441 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23442 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23443 id: id.to_owned(),
23444 session_id_hint: hint.to_owned(),
23445 modified_at_unix_ms: None,
23446 };
23447 assert_eq!(
23448 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23449 .unwrap(),
23450 "candidate-sole",
23451 );
23452 let missing = select_history_candidate(&[], None).unwrap_err();
23453 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23454 let ambiguous = select_history_candidate(
23455 &[
23456 candidate("candidate-a", "sidecar-session-a"),
23457 candidate("candidate-b", "sidecar-session-b"),
23458 ],
23459 None,
23460 )
23461 .unwrap_err();
23462 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23463 }
23464
23465 #[test]
23466 fn session_record_context_export_revalidation_rejects_record_change() {
23467 let session = terminal_address(1);
23468 let identity = ProviderSessionIdentity {
23469 key: ProviderSessionKey::SessionId,
23470 id: "provider-exact".to_owned(),
23471 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23472 };
23473 let mut expected = record("claude", "record-context-export");
23474 expected.state = ManagedSessionState::Live;
23475 expected.provider_session = Some(identity.clone());
23476 expected.active_session = Some(session.clone());
23477 let binding = SessionBinding {
23478 workspace_id: session.workspace_id.clone(),
23479 generation: session.session.generation,
23480 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23481 pending_resume: None,
23482 record_id: Some(expected.record_id.clone()),
23483 managed_worktree_lease_id: None,
23484 environment_profile: None,
23485 bundle: None,
23486 context: None,
23487 materialization_id: None,
23488 };
23489 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23490 assert!(session_record_context_export_binding_is_exact(
23491 &expected,
23492 &expected,
23493 Some(&identity),
23494 &session,
23495 &binding,
23496 &expected.provider,
23497 ¤t_root,
23498 false,
23499 ));
23500 let mut changed = expected.clone();
23501 changed.updated_at_unix_ms += 1;
23502 assert!(!session_record_context_export_binding_is_exact(
23503 &expected,
23504 &changed,
23505 Some(&identity),
23506 &session,
23507 &binding,
23508 &expected.provider,
23509 ¤t_root,
23510 false,
23511 ));
23512 assert!(!session_record_context_export_binding_is_exact(
23517 &expected,
23518 &changed,
23519 Some(&identity),
23520 &session,
23521 &binding,
23522 &expected.provider,
23523 ¤t_root,
23524 true,
23525 ));
23526 let mut detached = expected.clone();
23527 detached.state = ManagedSessionState::Dormant;
23528 detached.active_session = None;
23529 detached.updated_at_unix_ms += 1;
23530 assert!(!session_record_context_export_binding_is_exact(
23531 &expected,
23532 &detached,
23533 Some(&identity),
23534 &session,
23535 &binding,
23536 &expected.provider,
23537 ¤t_root,
23538 false,
23539 ));
23540 assert!(session_record_context_export_binding_is_exact(
23541 &expected,
23542 &detached,
23543 Some(&identity),
23544 &session,
23545 &binding,
23546 &expected.provider,
23547 ¤t_root,
23548 true,
23549 ));
23550 }
23551
23552 #[test]
23559 fn session_record_context_export_binding_is_exact_without_identity() {
23560 let session = terminal_address(1);
23561 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23562 expected.state = ManagedSessionState::Live;
23563 expected.active_session = Some(session.clone());
23564 let binding = SessionBinding {
23565 workspace_id: session.workspace_id.clone(),
23566 generation: session.session.generation,
23567 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23568 pending_resume: None,
23569 record_id: Some(expected.record_id.clone()),
23570 managed_worktree_lease_id: None,
23571 environment_profile: None,
23572 bundle: None,
23573 context: None,
23574 materialization_id: None,
23575 };
23576 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23577 assert!(session_record_context_export_binding_is_exact(
23578 &expected,
23579 &expected,
23580 None,
23581 &session,
23582 &binding,
23583 &expected.provider,
23584 ¤t_root,
23585 false,
23586 ));
23587 let mut drifted = expected.clone();
23591 drifted.provider_session = Some(ProviderSessionIdentity {
23592 key: ProviderSessionKey::SessionId,
23593 id: "unexpected-identity".to_owned(),
23594 transcript_path: None,
23595 });
23596 assert!(!session_record_context_export_binding_is_exact(
23597 &expected,
23598 &drifted,
23599 None,
23600 &session,
23601 &binding,
23602 &expected.provider,
23603 ¤t_root,
23604 false,
23605 ));
23606 }
23607
23608 #[test]
23609 fn session_record_context_export_preflight_requires_live_usable_source() {
23610 let mut source = record("claude", "record-context-export-preflight");
23611 source.state = ManagedSessionState::Live;
23612 assert!(session_record_context_export_source_is_usable(
23613 &source,
23614 &SessionStatus::Running,
23615 false,
23616 ));
23617 assert!(session_record_context_export_source_is_usable(
23618 &source,
23619 &SessionStatus::Exited { exit_code: Some(0) },
23620 false,
23621 ));
23622
23623 source.state = ManagedSessionState::Dormant;
23624 assert!(!session_record_context_export_source_is_usable(
23625 &source,
23626 &SessionStatus::Running,
23627 false,
23628 ));
23629 assert!(session_record_context_export_source_is_usable(
23633 &source,
23634 &SessionStatus::Exited { exit_code: Some(0) },
23635 true,
23636 ));
23637 assert!(!session_record_context_export_source_is_usable(
23638 &source,
23639 &SessionStatus::Exited { exit_code: Some(0) },
23640 false,
23641 ));
23642 source.state = ManagedSessionState::Live;
23643 assert!(!session_record_context_export_source_is_usable(
23644 &source,
23645 &SessionStatus::Starting,
23646 false,
23647 ));
23648 }
23649
23650 #[test]
23651 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23652 let catalog = active_registry().unwrap();
23653 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23654 let workspace = WorkspaceConfig::new(
23655 WorkspaceId::new("primary").unwrap(),
23656 std::env::current_dir().unwrap(),
23657 )
23658 .unwrap();
23659 let shared = NodeShared::new(
23660 handle,
23661 "fixture-token".to_owned(),
23662 NodeId::new("node-context-export-conflict").unwrap(),
23663 vec![workspace],
23664 vec![agent("claude")],
23665 );
23666 let history = HistorySessionRecord {
23667 session_id: "provider-exact".to_owned(),
23668 title: None,
23669 cwd: None,
23670 model: None,
23671 message_count: 1,
23672 completed_turn_count: None,
23673 total_tokens: 0,
23674 messages: vec![gate4agent_types::HistoryMessageRecord {
23675 role: gate4agent_types::HistoryMessageRole::User,
23676 text: "bounded context".to_owned(),
23677 }],
23678 };
23679 let pack = NodeContextPack::export(
23680 ContextPackLineageReceipt {
23681 source_node_id: shared.node_id.clone(),
23682 source_session: terminal_address(1),
23683 source_provider: agent("claude"),
23684 },
23685 &history,
23686 )
23687 .unwrap();
23688 let context_id = pack.receipt().id.clone();
23689 let session = terminal_address(1);
23690 let identity = ProviderSessionIdentity {
23691 key: ProviderSessionKey::SessionId,
23692 id: history.session_id.clone(),
23693 transcript_path: None,
23694 };
23695 let mut expected = record("claude", "record-context-export-final-conflict");
23696 expected.state = ManagedSessionState::Live;
23697 expected.provider_session = Some(identity.clone());
23698 expected.active_session = Some(session.clone());
23699 let mut changed = expected.clone();
23700 changed.updated_at_unix_ms += 1;
23701 shared.insert_record(changed).unwrap();
23702
23703 let error = shared
23704 .commit_context_pack_for_session_record(
23705 &expected,
23706 Some(&identity),
23707 &session,
23708 "candidate-exact",
23709 &history,
23710 pack,
23711 false,
23712 )
23713 .unwrap_err();
23714
23715 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23716 assert!(shared
23717 .context_catalog
23718 .read()
23719 .unwrap_or_else(|poisoned| poisoned.into_inner())
23720 .get(&context_id)
23721 .is_none());
23722 }
23723
23724 #[tokio::test]
23725 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23726 let root = temporary_workspace_root("session-task-binding");
23727 std::fs::create_dir_all(&root).unwrap();
23728 let state_path = root.join("node-state.json");
23729 let catalog = active_registry().unwrap();
23730 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23731 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23732 let node_id = NodeId::new("node-1").unwrap();
23733 let mut shared = NodeShared::new(
23734 handle,
23735 "fixture-token".to_owned(),
23736 node_id.clone(),
23737 vec![workspace],
23738 vec![agent("claude")],
23739 );
23740 shared.state_path = Some(state_path.clone());
23741 let record = record("claude", "sr-task");
23742 let record_id = record.record_id.clone();
23743 shared.insert_record(record).unwrap();
23744 shared.persist_state().unwrap();
23745 shared
23746 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23747 .unwrap();
23748
23749 let minted = process_request_inner(
23750 &shared,
23751 77,
23752 ClientRole::Operator,
23753 NodeRequest::SetSessionTask {
23754 record_id: record_id.clone(),
23755 expected_revision: 0,
23756 target: SessionTaskTargetV1::New,
23757 },
23758 )
23759 .await
23760 .unwrap();
23761 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23762 panic!("session task mutation returned another response");
23763 };
23764 let minted_binding = minted.task_binding.clone().unwrap();
23765 let minted_task_id = minted_binding.task_id.clone().unwrap();
23766 assert_eq!(minted_binding.revision, 1);
23767 assert!(minted_task_id.as_str().starts_with("task-"));
23768 assert_eq!(minted_task_id.as_str().len(), 29);
23769
23770 let event_count = shared.current_sequence();
23771 let repeated = process_request_inner(
23772 &shared,
23773 77,
23774 ClientRole::Operator,
23775 NodeRequest::SetSessionTask {
23776 record_id: record_id.clone(),
23777 expected_revision: 1,
23778 target: SessionTaskTargetV1::Existing {
23779 task_id: minted_task_id.clone(),
23780 },
23781 },
23782 )
23783 .await
23784 .unwrap();
23785 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23786 assert_eq!(shared.current_sequence(), event_count);
23787
23788 let stale = process_request_inner(
23789 &shared,
23790 77,
23791 ClientRole::Operator,
23792 NodeRequest::SetSessionTask {
23793 record_id: record_id.clone(),
23794 expected_revision: 0,
23795 target: SessionTaskTargetV1::Existing {
23796 task_id: minted_task_id,
23797 },
23798 },
23799 )
23800 .await
23801 .unwrap_err();
23802 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23803
23804 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23805 let assigned = process_request_inner(
23806 &shared,
23807 77,
23808 ClientRole::Operator,
23809 NodeRequest::SetSessionTask {
23810 record_id: record_id.clone(),
23811 expected_revision: 1,
23812 target: SessionTaskTargetV1::Existing {
23813 task_id: assigned_id.clone(),
23814 },
23815 },
23816 )
23817 .await
23818 .unwrap();
23819 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23820 panic!("session task assignment returned another response");
23821 };
23822 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23823 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23824
23825 let cleared = process_request_inner(
23826 &shared,
23827 77,
23828 ClientRole::Operator,
23829 NodeRequest::SetSessionTask {
23830 record_id: record_id.clone(),
23831 expected_revision: 2,
23832 target: SessionTaskTargetV1::Clear,
23833 },
23834 )
23835 .await
23836 .unwrap();
23837 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23838 panic!("session task clear returned another response");
23839 };
23840 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23841 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23842 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23843 assert_eq!(
23844 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23845 cleared.updated_at_unix_ms,
23846 );
23847
23848 let persisted = std::fs::read(&state_path).unwrap();
23849 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23850 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23851 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23852 std::fs::remove_dir_all(root).unwrap();
23853 }
23854
23855 #[test]
23856 fn session_task_capability_strips_snapshots_events_and_replies() {
23857 let mut bound = record("claude", "sr-task-projection");
23858 bound.updated_at_unix_ms = 2;
23859 bound.task_binding = Some(SessionTaskBindingV1 {
23860 revision: 1,
23861 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23862 changed_at_unix_ms: 2,
23863 });
23864 let request = NodeRequest::SetSessionTask {
23865 record_id: bound.record_id.clone(),
23866 expected_revision: 1,
23867 target: SessionTaskTargetV1::Clear,
23868 };
23869 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23870 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23871 }));
23872 assert!(!request_uses_unnegotiated_capability(
23873 &request,
23874 &baseline_capabilities().unwrap(),
23875 ));
23876 assert!(request_uses_unnegotiated_capability(&request, &[]));
23877
23878 let mut snapshot = terminal_test_shared().snapshot();
23879 snapshot.session_records.push(bound.clone());
23880 let mut reply = ResponseEnvelope {
23881 request_id: 1,
23882 result: Ok(NodeResponse::Resync {
23883 event_sequence: 1,
23884 oldest_available_sequence: 1,
23885 snapshot,
23886 events: vec![NodeEventEnvelope {
23887 sequence: 1,
23888 event: NodeEvent::SessionRecordUpserted {
23889 record: bound.clone(),
23890 },
23891 }],
23892 }),
23893 };
23894 project_response_without_session_task_binding(&mut reply);
23895 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23896 panic!("task projection changed the response kind");
23897 };
23898 assert!(snapshot.session_records[0].task_binding.is_none());
23899 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
23900 panic!("task projection changed the event kind");
23901 };
23902 assert!(record.task_binding.is_none());
23903
23904 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
23905 sequence: 2,
23906 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
23907 });
23908 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
23909 panic!("task event projection changed the event kind");
23910 };
23911 assert!(record.task_binding.is_none());
23912
23913 let mut direct = ResponseEnvelope {
23914 request_id: 2,
23915 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
23916 };
23917 project_response_without_session_task_binding(&mut direct);
23918 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
23919 panic!("task reply projection changed the response kind");
23920 };
23921 assert!(record.task_binding.is_none());
23922 }
23923
23924 #[test]
23925 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
23926 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
23927 let workspace = WorkspaceConfig::new(
23928 WorkspaceId::new("primary").unwrap(),
23929 std::env::current_dir().unwrap(),
23930 )
23931 .unwrap();
23932 let config = NodeServerConfig::new(
23933 DEFAULT_NODE_ENDPOINT,
23934 "fixture-token",
23935 NodeId::new("node-1").unwrap(),
23936 [workspace],
23937 )
23938 .unwrap();
23939 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
23940 }
23941
23942 #[cfg(all(feature = "fixture", windows))]
23943 #[test]
23944 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
23945 let root = temporary_workspace_root("provider-bundle-argv-hold");
23946 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
23947 std::fs::create_dir_all(&root).unwrap();
23948 std::fs::create_dir_all(&outside).unwrap();
23949 let proof_path = root.join("provider.proof");
23950 let release_signal = root.join("release.signal");
23951 let config = NodeServerConfig::new(
23952 format!(
23953 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
23954 std::process::id(),
23955 ),
23956 "fixture-token",
23957 NodeId::new("provider-bundle-hold-node").unwrap(),
23958 [WorkspaceConfig::new(
23959 WorkspaceId::new("primary").unwrap(),
23960 std::env::current_dir().unwrap(),
23961 )
23962 .unwrap()],
23963 )
23964 .unwrap()
23965 .with_spawn_profiles(SpawnProfileRegistry::new([
23966 crate::protocol::SpawnProfileDefaults {
23967 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
23968 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
23969 .unwrap(),
23970 provider: agent("kimi"),
23971 mode: SessionMode::Pty,
23972 terminal_size: gate4agent_types::TerminalSize {
23973 rows: 24,
23974 columns: 80,
23975 },
23976 prompt: None,
23977 bundle_id: None,
23978 context_id: None,
23979 environment_profile_id: None,
23980 },
23981 ])
23982 .unwrap());
23983 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
23984 config,
23985 agent("kimi"),
23986 proof_path.clone(),
23987 release_signal.clone(),
23988 )
23989 .unwrap();
23990 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
23991
23992 let spec = NodeServer::provider_bundle_argv_fixture_spec(
23993 agent("kimi"),
23994 proof_path.clone(),
23995 Some(release_signal.clone()),
23996 )
23997 .unwrap();
23998 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
23999 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24000 let release_wait = wrapper
24001 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24002 .unwrap();
24003 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24004 assert!(proof_write < release_wait);
24005 assert!(release_wait < fixture_ready);
24006 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24007 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24008 assert_eq!(
24009 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24010 &[
24011 proof_path.to_string_lossy().into_owned(),
24012 release_signal.to_string_lossy().into_owned(),
24013 ],
24014 );
24015
24016 let error = NodeServer::provider_bundle_argv_fixture_spec(
24017 agent("kimi"),
24018 proof_path,
24019 Some(outside.join("release.signal")),
24020 )
24021 .unwrap_err();
24022 assert!(matches!(
24023 error,
24024 NodeServerError::Registry(message)
24025 if message == "provider bundle argv release path must share the proof parent"
24026 ));
24027 drop(server);
24028 std::fs::remove_dir_all(root).unwrap();
24029 std::fs::remove_dir_all(outside).unwrap();
24030 }
24031
24032 #[cfg(feature = "fixture")]
24033 #[test]
24034 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24035 let root = temporary_workspace_root("context-fixture-registry");
24036 std::fs::create_dir_all(&root).unwrap();
24037 let proof_path = root.join("context.proof");
24038 let config = NodeServerConfig::new(
24039 format!(
24040 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24041 std::process::id(),
24042 ),
24043 "fixture-token",
24044 NodeId::new("context-fixture-node").unwrap(),
24045 [WorkspaceConfig::new(
24046 WorkspaceId::new("primary").unwrap(),
24047 std::env::current_dir().unwrap(),
24048 )
24049 .unwrap()],
24050 )
24051 .unwrap();
24052 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24053 let mut enabled = server
24054 .shared
24055 .enabled_providers
24056 .iter()
24057 .map(AgentId::as_str)
24058 .collect::<Vec<_>>();
24059 enabled.sort_unstable();
24060 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24061 for provider in &server.shared.enabled_providers {
24062 assert!(server
24063 .shared
24064 .provider_adapter_contracts
24065 .iter()
24066 .any(|contract| {
24067 &contract.provider == provider
24068 && contract.family == AdapterFamily::History
24069 }));
24070 }
24071 drop(server);
24072 std::fs::remove_dir_all(root).unwrap();
24073 }
24074
24075 #[cfg(feature = "fixture")]
24076 #[test]
24077 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24078 let root = temporary_workspace_root("monitoring-context-fixture");
24079 std::fs::create_dir_all(&root).unwrap();
24080 let proof_path = root.join("context.proof");
24081 let standard = NodeServer::context_pack_fixture_catalog(
24082 proof_path.clone(),
24083 None,
24084 false,
24085 ).unwrap();
24086 let monitoring = NodeServer::context_pack_fixture_catalog(
24087 proof_path.clone(),
24088 None,
24089 true,
24090 ).unwrap();
24091 let claude = AgentId::new("claude").unwrap();
24092 let standard_claude = standard.get(&claude).unwrap();
24093 let monitoring_claude = monitoring.get(&claude).unwrap();
24094 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24098 assert_eq!(
24099 monitoring_claude.capabilities.adapters.history,
24100 standard_claude.capabilities.adapters.history,
24101 );
24102 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24103 let codex = AgentId::new("codex").unwrap();
24104 let standard_codex = standard.get(&codex).unwrap();
24105 let monitoring_codex = monitoring.get(&codex).unwrap();
24106 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24107 assert_eq!(
24108 monitoring_codex.capabilities.adapters.history,
24109 standard_codex.capabilities.adapters.history,
24110 );
24111 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24112 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24113 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24114 .unwrap();
24115 for required in [
24116 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24117 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24118 "@('role', 'text')",
24119 "g4a-private-provider-session-canary",
24120 "private-provider-login-identity",
24121 "$rawProof",
24122 "$env:GATE4AGENT_HOOK_URL",
24123 "g4a-private-provider-session-canary",
24124 ] {
24125 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24126 }
24127 for provider in ["grok", "kimi"] {
24128 let provider = AgentId::new(provider).unwrap();
24129 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24130 }
24131
24132 let config = NodeServerConfig::new(
24133 format!(
24134 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24135 std::process::id(),
24136 ),
24137 "fixture-token",
24138 NodeId::new("monitoring-context-fixture-node").unwrap(),
24139 [WorkspaceConfig::new(
24140 WorkspaceId::new("primary").unwrap(),
24141 std::env::current_dir().unwrap(),
24142 )
24143 .unwrap()],
24144 )
24145 .unwrap();
24146 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24147 assert!(server.shared.fixture_semantic_hook_policy);
24148 drop(server);
24149 std::fs::remove_dir_all(root).unwrap();
24150 }
24151
24152 #[cfg(feature = "fixture")]
24153 #[test]
24154 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24155 let root = temporary_workspace_root("kimi-context-only-fixture");
24156 std::fs::create_dir_all(&root).unwrap();
24157 let proof_path = root.join("context.proof");
24158 let standard = NodeServer::context_pack_fixture_catalog(
24159 proof_path.clone(),
24160 None,
24161 false,
24162 ).unwrap();
24163 let kimi = AgentId::new("kimi").unwrap();
24164 let context_only = NodeServer::context_pack_fixture_catalog(
24165 proof_path,
24166 Some(&kimi),
24167 false,
24168 )
24169 .unwrap();
24170
24171 let codex = AgentId::new("codex").unwrap();
24172 assert_eq!(
24173 standard.get(&codex).unwrap().launch.fixed_args,
24174 context_only.get(&codex).unwrap().launch.fixed_args,
24175 );
24176 let script = context_only
24177 .get(&kimi)
24178 .unwrap()
24179 .launch
24180 .fixed_args
24181 .iter()
24182 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24183 .expect("Kimi context-only validation script is missing");
24184 for required in [
24185 "$bundleArgs.Count -ne 0",
24186 "GATE4AGENT_CONTEXT_ROOT",
24187 "context-pack.json",
24188 "$entries.Count -ne 1",
24189 "g4a-context-pack-v1",
24190 "$document.PSObject.Properties['cwd']",
24191 "'claude', 'codex', 'grok', 'kimi'",
24192 "$hasUser",
24193 "$hasAssistant",
24194 "SHA256",
24195 "context-only",
24196 ] {
24197 assert!(script.contains(required), "missing validation: {required}");
24198 }
24199 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24200 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24201 }
24202 assert!(!standard
24203 .get(&kimi)
24204 .unwrap()
24205 .launch
24206 .fixed_args
24207 .iter()
24208 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24209
24210 std::fs::remove_dir_all(root).unwrap();
24211 }
24212
24213 #[cfg(all(feature = "fixture", windows))]
24214 #[tokio::test]
24215 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24216 let root = temporary_workspace_root("kimi-context-only-spawn");
24217 let workspace_root = root.join("workspace");
24218 std::fs::create_dir_all(&workspace_root).unwrap();
24219 let materialization_root = root.join("private-materializations");
24220 let proof_path = root.join("context.proof");
24221 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24222 let workspace_id = WorkspaceId::new("primary").unwrap();
24223 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24224 let endpoint = format!(
24225 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24226 std::process::id(),
24227 unix_time_ms(),
24228 );
24229 let spawn_profiles = SpawnProfileRegistry::new([
24230 crate::protocol::SpawnProfileDefaults {
24231 profile_id: profile_id.clone(),
24232 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24233 provider: agent("kimi"),
24234 mode: SessionMode::Pty,
24235 terminal_size: gate4agent_types::TerminalSize {
24236 rows: 30,
24237 columns: 120,
24238 },
24239 prompt: None,
24240 bundle_id: None,
24241 context_id: None,
24242 environment_profile_id: None,
24243 },
24244 ])
24245 .unwrap();
24246 let mut config = NodeServerConfig::new(
24247 &endpoint,
24248 "fixture-token",
24249 node_id.clone(),
24250 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24251 )
24252 .unwrap()
24253 .with_spawn_profiles(spawn_profiles)
24254 .with_session_environment_materialization(
24255 materialization_root,
24256 Arc::new(FixtureSecretResolver {
24257 deny: Arc::new(AtomicBool::new(false)),
24258 }),
24259 )
24260 .unwrap();
24261 config.fixture_raw_pty_runtime = true;
24262 let server = NodeServer::new_context_only_proof_fixture(
24263 config,
24264 agent("kimi"),
24265 proof_path.clone(),
24266 )
24267 .unwrap();
24268 let shared = Arc::clone(&server.shared);
24269 let shutdown = server.shutdown_handle();
24270
24271 let history = HistorySessionRecord {
24272 session_id: "context-only-source-history".to_owned(),
24273 title: None,
24274 cwd: None,
24275 model: None,
24276 message_count: 2,
24277 completed_turn_count: None,
24278 total_tokens: 0,
24279 messages: vec![
24280 gate4agent_types::HistoryMessageRecord {
24281 role: gate4agent_types::HistoryMessageRole::User,
24282 text: "continue the bounded parent run through native C2".to_owned(),
24283 },
24284 gate4agent_types::HistoryMessageRecord {
24285 role: gate4agent_types::HistoryMessageRole::Assistant,
24286 text: "the exact parent context is ready for the child run".to_owned(),
24287 },
24288 ],
24289 };
24290 let pack = NodeContextPack::export(
24291 ContextPackLineageReceipt {
24292 source_node_id: node_id.clone(),
24293 source_session: SessionAddress {
24294 workspace_id: workspace_id.clone(),
24295 session: SessionKey {
24296 instance_id: AgentInstanceId(71),
24297 generation: SessionGeneration(1),
24298 },
24299 },
24300 source_provider: agent("codex"),
24301 },
24302 &history,
24303 )
24304 .unwrap();
24305 let expected_context_hash = digest(&SHA256, pack.bytes())
24306 .as_ref()
24307 .iter()
24308 .map(|byte| format!("{byte:02x}"))
24309 .collect::<String>();
24310 let context = shared
24311 .context_catalog
24312 .write()
24313 .unwrap_or_else(|poisoned| poisoned.into_inner())
24314 .insert(pack)
24315 .unwrap();
24316 let spec = SpawnSpec {
24317 target: crate::protocol::SpawnTarget {
24318 node_id,
24319 workspace_id: workspace_id.clone(),
24320 worktree_id: None,
24321 },
24322 profile_id,
24323 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24324 "target-kimi-r1",
24325 ).unwrap(),
24326 overrides: crate::protocol::SpawnOverrides {
24327 context_id: crate::protocol::SpawnOverride::Set {
24328 value: context.id.clone(),
24329 },
24330 ..crate::protocol::SpawnOverrides::default()
24331 },
24332 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24333 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24334 required_capabilities: SpawnRequiredCapabilities::default(),
24335 };
24336 let resolved = match shared.resolve_spawn_spec(&spec) {
24337 Ok(resolved) => resolved,
24338 Err(error) => panic!(
24339 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24340 error.code,
24341 error.message,
24342 ),
24343 };
24344 let resolved_context = match shared.resolve_context(&resolved) {
24345 Ok(Some(resolved_context)) => resolved_context,
24346 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24347 Err(error) => panic!(
24348 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24349 error.code,
24350 error.message,
24351 ),
24352 };
24353 assert_eq!(resolved_context, context);
24354 let runtime_policy = match shared
24355 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24356 .await
24357 {
24358 Ok(runtime_policy) => runtime_policy,
24359 Err(error) => panic!(
24360 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24361 error.code,
24362 error.message,
24363 ),
24364 };
24365 assert!(runtime_policy.raw_pty_lifecycle);
24366
24367 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24368 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24369 agent("kimi"),
24370 TransportKind::Pty,
24371 vec![EnvMutation {
24372 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24373 value: Some(OsString::from("must-remain-rejected")),
24374 }],
24375 )
24376 .unwrap();
24377 assert_eq!(
24378 launch_control
24379 .install_native_launch_environment_overlay(
24380 AgentInstanceId(72),
24381 generic_overlay,
24382 )
24383 .unwrap_err(),
24384 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24385 );
24386
24387 let diagnostic_address = SessionAddress {
24388 workspace_id: workspace_id.clone(),
24389 session: SessionKey {
24390 instance_id: AgentInstanceId(73),
24391 generation: SessionGeneration(1),
24392 },
24393 };
24394 let (diagnostic_overlay, diagnostic_materialization) = match shared
24395 .prepare_session_materialization(
24396 &diagnostic_address,
24397 &agent("kimi"),
24398 SessionMode::Pty,
24399 None,
24400 None,
24401 Some(&resolved_context),
24402 None,
24403 )
24404 {
24405 Ok(Some(prepared)) => prepared,
24406 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24407 Err(error) => panic!(
24408 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24409 error.code,
24410 error.message,
24411 ),
24412 };
24413 let diagnostic_overlay = diagnostic_overlay
24414 .expect("Kimi context-only materialization omitted its environment overlay");
24415 assert!(matches!(
24416 &diagnostic_overlay,
24417 PreparedNativeLaunchOverlay::Environment(_),
24418 ));
24419 match shared.install_prepared_launch_overlay(
24420 diagnostic_address.session.instance_id,
24421 diagnostic_overlay,
24422 ) {
24423 Ok(None) => {}
24424 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24425 Err(error) => panic!(
24426 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24427 error.code,
24428 error.message,
24429 ),
24430 }
24431 assert!(launch_control.clear_native_instance_launch_overlay(
24432 diagnostic_address.session.instance_id,
24433 ));
24434 drop(diagnostic_materialization);
24435 assert!(shared
24436 .materializations
24437 .lock()
24438 .unwrap_or_else(|poisoned| poisoned.into_inner())
24439 .is_empty());
24440
24441 let node_task = tokio::spawn(server.run());
24442 let ready_client = timeout(Duration::from_secs(5), async {
24443 loop {
24444 match gate4agent_node_wire::LocalNodeClient::connect(
24445 &endpoint,
24446 &shared.node_id,
24447 ClientRole::Observer,
24448 "fixture-token",
24449 )
24450 .await
24451 {
24452 Ok(client) => break client,
24453 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24454 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24455 }
24456 }
24457 })
24458 .await
24459 .expect("Kimi context-only Node did not become ready");
24460 assert_eq!(
24461 ready_client
24462 .hello()
24463 .snapshot
24464 .provider_runtime_statuses
24465 .iter()
24466 .find(|status| status.provider().as_str() == "kimi")
24467 .expect("Kimi runtime status is missing")
24468 .mode(),
24469 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24470 );
24471 drop(ready_client);
24472
24473 let receipt = match shared.spawn_from_spec(spec).await {
24474 Ok(receipt) => receipt,
24475 Err(error) => panic!(
24476 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24477 error.code,
24478 error.message,
24479 ),
24480 };
24481 assert_eq!(receipt.provider, agent("kimi"));
24482 assert_eq!(receipt.context.as_ref(), Some(&context));
24483 assert!(receipt.context_binding_is_valid());
24484 timeout(Duration::from_secs(5), async {
24485 while !proof_path.is_file() {
24486 sleep(Duration::from_millis(10)).await;
24487 }
24488 })
24489 .await
24490 .expect("Kimi context-only fixture did not write its proof");
24491
24492 let proof = std::fs::read_to_string(&proof_path).unwrap();
24493 let lines = proof.lines().collect::<Vec<_>>();
24494 assert_eq!(lines.len(), 7);
24495 assert_eq!(lines[0], "context-only");
24496 assert!(Path::new(lines[1]).is_absolute());
24497 assert_ne!(
24498 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24499 std::fs::canonicalize(&workspace_root).unwrap(),
24500 );
24501 assert_eq!(
24502 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24503 std::fs::canonicalize(&workspace_root).unwrap(),
24504 );
24505 assert_eq!(lines[3], expected_context_hash);
24506 assert_eq!(lines[4], "g4a-context-pack-v1");
24507 assert_eq!(lines[5], "codex");
24508 assert_eq!(lines[6], "2");
24509 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24510 session.instance_id == receipt.session.session.instance_id
24511 && session.agent_id.as_str() == "kimi"
24512 }));
24513
24514 shutdown.request_shutdown().await.unwrap();
24515 timeout(Duration::from_secs(5), node_task)
24516 .await
24517 .unwrap()
24518 .unwrap()
24519 .unwrap();
24520 drop(shutdown);
24521 drop(shared);
24522 std::fs::remove_dir_all(root).unwrap();
24523 }
24524
24525 #[cfg(feature = "fixture")]
24526 #[test]
24527 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24528 let root = temporary_workspace_root("clean-exit-constructor");
24529 std::fs::create_dir_all(&root).unwrap();
24530 let config = construction_test_config("clean-exit-constructor")
24531 .with_state_path(root.join("node-state.json"))
24532 .unwrap();
24533 let server = NodeServer::new_clean_exit_fixture(
24534 config,
24535 root.clone(),
24536 root.join("started.marker"),
24537 root.join("release.signal"),
24538 )
24539 .unwrap();
24540 assert_eq!(
24541 server
24542 .shared
24543 .enabled_providers
24544 .iter()
24545 .map(AgentId::as_str)
24546 .collect::<Vec<_>>(),
24547 ["claude"],
24548 );
24549 drop(server);
24550 std::fs::remove_dir_all(root).unwrap();
24551 }
24552
24553 #[test]
24554 fn production_registry_provider_contract_manifest_is_exact() {
24555 let registry = active_registry().unwrap();
24556 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24557 assert_eq!(
24558 providers
24559 .iter()
24560 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24561 .collect::<Vec<_>>(),
24562 vec![
24563 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24564 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24565 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24566 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24567 ]
24568 );
24569 assert_eq!(
24570 adapters
24571 .iter()
24572 .map(|contract| (
24573 contract.provider.as_str(),
24574 contract.family,
24575 contract.adapter_id.as_str(),
24576 contract.revision.as_str(),
24577 ))
24578 .collect::<Vec<_>>(),
24579 vec![
24580 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24581 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24582 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24583 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24584 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24585 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24586 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24587 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24588 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24589 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24590 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24591 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24592 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24593 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24594 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24595 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24596 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24597 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24598 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24599 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24600 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24601 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24602 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24603 ]
24604 );
24605 }
24606
24607 fn construction_test_config(name: &str) -> NodeServerConfig {
24608 let workspace = WorkspaceConfig::new(
24609 WorkspaceId::new("primary").unwrap(),
24610 std::env::current_dir().unwrap(),
24611 )
24612 .unwrap();
24613 NodeServerConfig::new(
24614 format!(r"\\.\pipe\gate4agent-{name}"),
24615 "fixture-token",
24616 NodeId::new(name).unwrap(),
24617 [workspace],
24618 )
24619 .unwrap()
24620 }
24621
24622 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24623 let adapter = AdapterBinding::new(
24624 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24625 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24626 gate4agent_types::AdapterVerification::SyntheticFixture,
24627 )
24628 .unwrap();
24629 let agent_ids = ["claude", "codex", "kimi"]
24630 .into_iter()
24631 .map(|agent_id| AgentId::new(agent_id).unwrap())
24632 .collect::<Vec<_>>();
24633 let adapters = gate4agent_catalog::AdapterRegistry::new(
24634 [
24635 AdapterFamily::PtySemantic,
24636 AdapterFamily::Pipe,
24637 AdapterFamily::OneShot,
24638 AdapterFamily::Acp,
24639 AdapterFamily::Hook,
24640 AdapterFamily::ManagedHook,
24641 AdapterFamily::History,
24642 AdapterFamily::Resume,
24643 AdapterFamily::SessionOptions,
24644 AdapterFamily::CapabilityProbe,
24645 ]
24646 .into_iter()
24647 .map(|family| gate4agent_catalog::AdapterDescriptor {
24648 family,
24649 binding: adapter.clone(),
24650 agents: agent_ids.clone(),
24651 }),
24652 )
24653 .unwrap();
24654 let specs = agent_ids
24655 .iter()
24656 .map(|agent_id| {
24657 let agent_id = agent_id.as_str();
24658 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24659 spec.revision =
24660 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24661 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24662 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24663 adapter: adapter.clone(),
24664 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24665 launch_override: None,
24666 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24667 });
24668 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24669 adapter: adapter.clone(),
24670 launch_override: None,
24671 });
24672 spec.capabilities.adapters.hook = Some(adapter.clone());
24673 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24674 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24675 spec.capabilities.adapters.history = Some(adapter.clone());
24676 spec.capabilities.adapters.resume = Some(adapter.clone());
24677 spec.capabilities.adapters.session_options = Some(adapter.clone());
24678 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24679 spec
24680 })
24681 .collect::<Vec<_>>();
24682 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24683 }
24684
24685 #[test]
24693 fn node_server_construction_accepts_the_production_manifest() {
24694 let registry = active_registry().unwrap();
24695 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24696 assert_eq!(providers.len(), registry.iter().count());
24697 assert!(!providers.is_empty());
24698 let server = NodeServer::new_with_registry(
24699 construction_test_config("manifest-production-bound"),
24700 registry,
24701 )
24702 .unwrap();
24703 drop(server);
24704 }
24705
24706 #[test]
24707 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24708 let registry = maximum_size_provider_manifest_registry();
24709 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24710 assert_eq!(providers.len(), 3);
24711 assert_eq!(adapters.len(), 30);
24712 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24713 let error = match NodeServer::new_with_registry(
24714 construction_test_config("manifest-handshake-overflow"),
24715 registry,
24716 ) {
24717 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24718 Err(error) => error,
24719 };
24720 assert!(matches!(
24721 error,
24722 NodeServerError::ProviderContractManifest(message)
24723 if message.contains("authentication binding length")
24724 && message.contains("8192-byte limit")
24725 ));
24726 }
24727
24728 #[test]
24729 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24730 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24731 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24732 let registry = AgentRegistry::new([spec]).unwrap();
24733 assert!(matches!(
24734 provider_contract_manifest(®istry),
24735 Err(NodeServerError::ProviderContractManifest(message))
24736 if message.contains("exceeds the 128-byte limit")
24737 ));
24738 }
24739
24740 #[test]
24741 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24742 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24743 spec.revision = "invalid revision".to_owned();
24744 let registry = AgentRegistry::new([spec]).unwrap();
24745 assert!(matches!(
24746 provider_contract_manifest(®istry),
24747 Err(NodeServerError::ProviderContractManifest(message))
24748 if message.contains("bounded lowercase ASCII")
24749 ));
24750 }
24751
24752 #[test]
24753 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24754 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24755 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24756 let conflicting_binding = builtin_registry()
24757 .get_by_id("codex")
24758 .unwrap()
24759 .capabilities
24760 .adapters
24761 .one_shot
24762 .clone()
24763 .unwrap();
24764 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24765 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24766 pipe.adapter = transport_binding;
24767 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24768 let registry = AgentRegistry::new([spec]).unwrap();
24769 assert!(matches!(
24770 provider_contract_manifest(®istry),
24771 Err(NodeServerError::ProviderContractManifest(message))
24772 if message.contains("conflicting OneShot adapter bindings")
24773 ));
24774 }
24775
24776 #[test]
24777 fn public_node_failures_expose_only_fixed_categories() {
24778 let provider = failure(
24779 NodeFailureCode::BackendOperationFailed,
24780 r"provider bearer-secret failed at C:\private\session.jsonl",
24781 );
24782 assert_eq!(provider.message, "backend-operation-failed");
24783 assert!(!provider.message.contains("bearer-secret"));
24784 assert!(!provider.message.contains(r"C:\private"));
24785 let persistence = persistence_failure(io::Error::new(
24786 io::ErrorKind::Other,
24787 r"state write failed at C:\private\state-v1.json",
24788 ));
24789 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24790 assert!(!persistence.message.contains(r"C:\private"));
24791
24792 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24793 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24794 assert_eq!(
24795 generic.to_string(),
24796 "node durable state failed: durable-state-load-failed",
24797 );
24798 }
24799 }
24800
24801 #[test]
24802 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24803 let sequence = b"\x1b[1;5D".to_vec();
24804 assert_eq!(
24805 terminal_bytes_action(sequence.clone()).unwrap(),
24806 InputAction::TerminalBytes(sequence),
24807 );
24808 assert_eq!(
24809 terminal_bytes_action(Vec::new()).unwrap_err().code,
24810 NodeFailureCode::InvalidRequest,
24811 );
24812 assert_eq!(
24813 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24814 .unwrap_err()
24815 .code,
24816 NodeFailureCode::InvalidRequest,
24817 );
24818 }
24819
24820 #[test]
24821 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24822 let root = temporary_workspace_root("exclusive-state-owner");
24823 std::fs::create_dir_all(&root).unwrap();
24824 let workspace = WorkspaceConfig::new(
24825 WorkspaceId::new("primary").unwrap(),
24826 &root,
24827 )
24828 .unwrap();
24829 let state_path = root.join("state-v1.json");
24830 let endpoint = format!(
24831 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24832 std::process::id(),
24833 std::time::SystemTime::now()
24834 .duration_since(std::time::UNIX_EPOCH)
24835 .unwrap()
24836 .as_nanos(),
24837 );
24838 let config = NodeServerConfig::new(
24839 endpoint,
24840 "fixture-token",
24841 NodeId::new("node-1").unwrap(),
24842 [workspace],
24843 )
24844 .unwrap()
24845 .with_state_path(&state_path)
24846 .unwrap();
24847
24848 let first = NodeServer::new(config.clone()).unwrap();
24849 let error = match NodeServer::new(config.clone()) {
24850 Ok(_) => panic!("a second node server acquired the same durable state path"),
24851 Err(error) => error,
24852 };
24853 assert!(matches!(
24854 &error,
24855 NodeServerError::DurableState(error)
24856 if error.kind() == io::ErrorKind::WouldBlock
24857 ));
24858 assert_eq!(
24859 error.to_string(),
24860 "node durable state failed: durable-state-lock-failed",
24861 );
24862 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24863 drop(first);
24864 let second = NodeServer::new(config).unwrap();
24865 drop(second);
24866 std::fs::remove_dir_all(root).unwrap();
24867 }
24868
24869 #[test]
24870 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24871 let catalog = active_registry().unwrap();
24872 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24873 let workspace_id = WorkspaceId::new("primary").unwrap();
24874 let workspace = WorkspaceConfig::new(
24875 workspace_id.clone(),
24876 std::env::current_dir().unwrap(),
24877 )
24878 .unwrap();
24879 let shared = NodeShared::new(
24880 handle,
24881 "fixture-token".to_owned(),
24882 NodeId::new("node-1").unwrap(),
24883 vec![workspace.clone()],
24884 vec![agent("claude"), agent("codex"), agent("kimi")],
24885 );
24886 let instance_id = AgentInstanceId(17);
24887 let generation = SessionGeneration(3);
24888 assert!(shared.address_for(instance_id, generation).is_none());
24889 shared.bind_session(instance_id, workspace_id.clone(), generation);
24890 let address = SessionAddress {
24891 workspace_id,
24892 session: SessionKey { instance_id, generation },
24893 };
24894 assert_eq!(
24895 shared.workspace_root(&address.workspace_id).unwrap(),
24896 workspace.canonical_root,
24897 );
24898 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
24899 shared.remove_binding(&address);
24900 assert!(shared.address_for(instance_id, generation).is_none());
24901 }
24902
24903 #[test]
24904 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
24905 let address = SessionAddress {
24906 workspace_id: WorkspaceId::new("primary").unwrap(),
24907 session: SessionKey {
24908 instance_id: AgentInstanceId(7),
24909 generation: SessionGeneration(2),
24910 },
24911 };
24912 let record_id = SessionRecordId::new("record-a").unwrap();
24913 let size = gate4agent_types::TerminalSize {
24914 rows: 24,
24915 columns: 80,
24916 };
24917 let address_requests = [
24918 NodeRequest::Resume {
24919 session: address.clone(),
24920 terminal_size: size,
24921 initial_prompt: None,
24922 },
24923 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
24924 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
24925 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
24926 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
24927 NodeRequest::TerminalControl {
24928 session: address.clone(),
24929 control: TerminalControl::Enter,
24930 },
24931 NodeRequest::Resize { session: address.clone(), size },
24932 NodeRequest::Interrupt { session: address.clone() },
24933 NodeRequest::Stop { session: address.clone(), force: false },
24934 NodeRequest::Remove { session: address },
24935 ];
24936 for request in &address_requests {
24937 assert!(request_requires_open_provider_ids_with(
24938 request,
24939 |_| Some(agent("third-party-agent")),
24940 |_| Some(agent("claude")),
24941 ));
24942 assert!(!request_requires_open_provider_ids_with(
24943 request,
24944 |_| Some(agent("claude")),
24945 |_| Some(agent("claude")),
24946 ));
24947 assert!(request_requires_open_provider_ids_with(
24948 request,
24949 |_| None,
24950 |_| Some(agent("claude")),
24951 ));
24952 }
24953
24954 let record_requests = [
24955 NodeRequest::RenameSessionRecord {
24956 record_id: record_id.clone(),
24957 display_name: "renamed".to_owned(),
24958 },
24959 NodeRequest::ResumeSessionRecord {
24960 record_id: record_id.clone(),
24961 terminal_size: size,
24962 initial_prompt: None,
24963 },
24964 NodeRequest::ForgetSessionRecord { record_id },
24965 ];
24966 for request in &record_requests {
24967 assert!(request_requires_open_provider_ids_with(
24968 request,
24969 |_| Some(agent("claude")),
24970 |_| Some(agent("grok")),
24971 ));
24972 assert!(!request_requires_open_provider_ids_with(
24973 request,
24974 |_| Some(agent("claude")),
24975 |_| Some(agent("kimi")),
24976 ));
24977 assert!(request_requires_open_provider_ids_with(
24978 request,
24979 |_| Some(agent("claude")),
24980 |_| None,
24981 ));
24982 }
24983
24984 assert!(request_requires_open_provider_ids_with(
24985 &NodeRequest::Spawn {
24986 workspace_id: WorkspaceId::new("primary").unwrap(),
24987 provider: agent("third-party-agent"),
24988 mode: SessionMode::Pty,
24989 terminal_size: size,
24990 initial_prompt: None,
24991 },
24992 |_| None,
24993 |_| None,
24994 ));
24995 assert!(!request_requires_open_provider_ids_with(
24996 &NodeRequest::Snapshot,
24997 |_| None,
24998 |_| None,
24999 ));
25000 assert!(request_requires_open_provider_ids_with(
25001 &NodeRequest::ForgetContextPack {
25002 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25003 },
25004 |_| None,
25005 |_| None,
25006 ));
25007 }
25008
25009 #[test]
25010 fn legacy_projection_filters_open_record_replies_and_event_history() {
25011 let catalog = active_registry().unwrap();
25012 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25013 let workspace = WorkspaceConfig::new(
25014 WorkspaceId::new("primary").unwrap(),
25015 std::env::current_dir().unwrap(),
25016 )
25017 .unwrap();
25018 let shared = NodeShared::new(
25019 handle,
25020 "fixture-token".to_owned(),
25021 NodeId::new("node-1").unwrap(),
25022 vec![workspace],
25023 vec![agent("claude"), agent("grok")],
25024 );
25025 let legacy = record("claude", "legacy-record");
25026 let open = record("grok", "open-record");
25027 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25028 record: legacy.clone(),
25029 });
25030 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25031 record: open.clone(),
25032 });
25033 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25034 record_id: legacy.record_id.clone(),
25035 });
25036 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25037 record_id: open.record_id.clone(),
25038 });
25039 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25040 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25041 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25042 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25043
25044 let mut record_reply = ResponseEnvelope {
25045 request_id: 1,
25046 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25047 };
25048 project_response_legacy_provider_ids(&shared, &mut record_reply);
25049 assert!(matches!(
25050 record_reply.result,
25051 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25052 ));
25053
25054 let mut resync = ResponseEnvelope {
25055 request_id: 2,
25056 result: Ok(NodeResponse::Resync {
25057 event_sequence: open_removed.sequence,
25058 oldest_available_sequence: 1,
25059 snapshot: shared.snapshot(),
25060 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25061 }),
25062 };
25063 project_response_legacy_provider_ids(&shared, &mut resync);
25064 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25065 panic!("legacy projection changed the resync response kind");
25066 };
25067 assert!(snapshot.session_records.iter().all(|item| {
25068 provider_id_is_legacy(&item.provider)
25069 }));
25070 assert_eq!(events.len(), 2);
25071 assert!(events.iter().all(|event| matches!(
25072 &event.event,
25073 NodeEvent::SessionRecordUpserted { record }
25074 if provider_id_is_legacy(&record.provider)
25075 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25076 }
25077
25078 #[test]
25079 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25080 let catalog = active_registry().unwrap();
25081 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25082 let workspace_id = WorkspaceId::new("primary").unwrap();
25083 let workspace = WorkspaceConfig::new(
25084 workspace_id.clone(),
25085 std::env::current_dir().unwrap(),
25086 )
25087 .unwrap();
25088 let shared = NodeShared::new(
25089 handle,
25090 "fixture-token".to_owned(),
25091 NodeId::new("node-1").unwrap(),
25092 vec![workspace],
25093 vec![agent("claude")],
25094 );
25095 for index in 0..CONTROL_SESSIONS_MAX {
25096 shared.bind_session(
25097 AgentInstanceId((index + 1) as u64),
25098 workspace_id.clone(),
25099 SessionGeneration(1),
25100 );
25101 }
25102 let error = shared.ensure_binding_capacity().unwrap_err();
25103 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25104 }
25105
25106 #[tokio::test]
25107 async fn provider_spawn_admission_rejects_before_session_mutation() {
25108 let catalog = active_registry().unwrap();
25109 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25110 let workspace = WorkspaceConfig::new(
25111 WorkspaceId::new("primary").unwrap(),
25112 std::env::current_dir().unwrap(),
25113 )
25114 .unwrap();
25115 let mut shared = NodeShared::new(
25116 handle,
25117 "fixture-token".to_owned(),
25118 NodeId::new("node-provider-admission").unwrap(),
25119 vec![workspace],
25120 vec![agent("claude")],
25121 );
25122 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25123 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25124 ])
25125 .unwrap();
25126 let terminal_size = gate4agent_types::TerminalSize {
25127 rows: 24,
25128 columns: 80,
25129 };
25130
25131 let unavailable = shared
25132 .spawn_session(
25133 WorkspaceId::new("primary").unwrap(),
25134 agent("claude"),
25135 SessionMode::Pty,
25136 terminal_size,
25137 None,
25138 )
25139 .await
25140 .unwrap_err();
25141 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25142 assert_eq!(unavailable.message, "backend-operation-failed");
25143
25144 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25145 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25146 agent("claude"),
25147 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25148 ),
25149 ])
25150 .unwrap();
25151 let semantic = shared
25152 .spawn_session(
25153 WorkspaceId::new("primary").unwrap(),
25154 agent("claude"),
25155 SessionMode::Inline,
25156 terminal_size,
25157 None,
25158 )
25159 .await
25160 .unwrap_err();
25161 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25162 assert_eq!(semantic.message, "unsupported-capability");
25163
25164 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25165 assert!(shared
25166 .session_bindings
25167 .lock()
25168 .unwrap_or_else(|poisoned| poisoned.into_inner())
25169 .is_empty());
25170 assert!(shared
25171 .session_records
25172 .lock()
25173 .unwrap_or_else(|poisoned| poisoned.into_inner())
25174 .records
25175 .is_empty());
25176 assert!(shared.handle.snapshot().sessions.is_empty());
25177 }
25178
25179 #[tokio::test]
25180 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25181 let catalog = active_registry().unwrap();
25182 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25183 let workspace_id = WorkspaceId::new("primary").unwrap();
25184 let workspace = WorkspaceConfig::new(
25185 workspace_id.clone(),
25186 std::env::current_dir().unwrap(),
25187 )
25188 .unwrap();
25189 let shared = NodeShared::new(
25190 handle,
25191 "fixture-token".to_owned(),
25192 NodeId::new("node-raw-spawn-record").unwrap(),
25193 vec![workspace],
25194 vec![agent("claude")],
25195 );
25196 let address = SessionAddress {
25197 workspace_id,
25198 session: SessionKey {
25199 instance_id: AgentInstanceId(71),
25200 generation: SessionGeneration::default(),
25201 },
25202 };
25203
25204 let record_id = shared
25205 .bind_spawn_session(
25206 &address,
25207 agent("claude"),
25208 SessionMode::Pty,
25209 ProviderRuntimePolicy::raw_pty(),
25210 SpawnRecordPolicy::Always,
25211 None,
25212 )
25213 .unwrap()
25214 .unwrap();
25215 shared
25216 .handle
25217 .dispatch(shared.prepare_command(ControlCommand::Register {
25218 instance_id: address.session.instance_id,
25219 agent_id: agent("claude"),
25220 transport: TransportKind::Pty,
25221 }))
25222 .unwrap();
25223 runtime.tick().await;
25224
25225 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25226 session.instance_id == address.session.instance_id
25227 && session.generation == address.session.generation
25228 }));
25229 let record = shared.record(&record_id).unwrap();
25230 assert_eq!(record.display_name, "claude #1");
25231 assert_eq!(record.state, ManagedSessionState::Live);
25232 assert_eq!(record.active_session.as_ref(), Some(&address));
25233 assert!(record.provider_session.is_none());
25234 let binding_record_id = shared
25235 .session_bindings
25236 .lock()
25237 .unwrap_or_else(|poisoned| poisoned.into_inner())
25238 .get(&address.session.instance_id)
25239 .and_then(|binding| binding.record_id.clone());
25240 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25241
25242 let renamed = shared
25243 .rename_session_record(&record_id, "raw review".to_owned())
25244 .unwrap();
25245 assert_eq!(renamed.record_id, record_id);
25246 assert_eq!(renamed.display_name, "raw review");
25247 shared.reconcile_managed_record(
25248 &record_id,
25249 &address,
25250 &ControlEvent {
25251 sequence: 1,
25252 command_id: None,
25253 instance_id: address.session.instance_id,
25254 generation: address.session.generation,
25255 event: ControlEventKind::Running { process_id: None },
25256 },
25257 false,
25258 false,
25259 );
25260 let running = shared.record(&record_id).unwrap();
25261 assert_eq!(running.state, ManagedSessionState::Live);
25262 assert_eq!(running.active_session.as_ref(), Some(&address));
25263 assert_eq!(running.display_name, "raw review");
25264
25265 shared.reconcile_managed_record(
25266 &record_id,
25267 &address,
25268 &ControlEvent {
25269 sequence: 2,
25270 command_id: None,
25271 instance_id: address.session.instance_id,
25272 generation: address.session.generation,
25273 event: ControlEventKind::Exited {
25274 exit_code: Some(0),
25275 forced: false,
25276 },
25277 },
25278 false,
25279 false,
25280 );
25281 let detached = shared.record(&record_id).unwrap();
25282 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25283 assert!(detached.active_session.is_none());
25284 assert_eq!(detached.display_name, "raw review");
25285 let renamed_detached = shared
25286 .rename_session_record(&record_id, "raw review detached".to_owned())
25287 .unwrap();
25288 assert_eq!(renamed_detached.record_id, record_id);
25289 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25290 assert!(renamed_detached.active_session.is_none());
25291 assert_eq!(renamed_detached.display_name, "raw review detached");
25292 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25293 }
25294
25295 #[test]
25296 fn legacy_raw_spawn_binding_remains_untracked() {
25297 let catalog = active_registry().unwrap();
25298 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25299 let workspace_id = WorkspaceId::new("primary").unwrap();
25300 let workspace = WorkspaceConfig::new(
25301 workspace_id.clone(),
25302 std::env::current_dir().unwrap(),
25303 )
25304 .unwrap();
25305 let shared = NodeShared::new(
25306 handle,
25307 "fixture-token".to_owned(),
25308 NodeId::new("node-legacy-raw-spawn").unwrap(),
25309 vec![workspace],
25310 vec![agent("claude")],
25311 );
25312 let address = SessionAddress {
25313 workspace_id,
25314 session: SessionKey {
25315 instance_id: AgentInstanceId(72),
25316 generation: SessionGeneration::default(),
25317 },
25318 };
25319
25320 let record_id = shared
25321 .bind_spawn_session(
25322 &address,
25323 agent("claude"),
25324 SessionMode::Pty,
25325 ProviderRuntimePolicy::raw_pty(),
25326 SpawnRecordPolicy::ProviderIdentityOnly,
25327 None,
25328 )
25329 .unwrap();
25330
25331 assert!(record_id.is_none());
25332 let binding_record_id = shared
25333 .session_bindings
25334 .lock()
25335 .unwrap_or_else(|poisoned| poisoned.into_inner())
25336 .get(&address.session.instance_id)
25337 .and_then(|binding| binding.record_id.clone());
25338 assert!(binding_record_id.is_none());
25339 assert!(shared.snapshot().session_records.is_empty());
25340 }
25341
25342 #[tokio::test]
25343 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25344 let catalog = active_registry().unwrap();
25345 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25346 let workspace_id = WorkspaceId::new("primary").unwrap();
25347 let workspace = WorkspaceConfig::new(
25348 workspace_id.clone(),
25349 std::env::current_dir().unwrap(),
25350 )
25351 .unwrap();
25352 let mut shared = NodeShared::new(
25353 handle,
25354 "fixture-token".to_owned(),
25355 NodeId::new("node-raw-semantic-gate").unwrap(),
25356 vec![workspace],
25357 vec![agent("claude")],
25358 );
25359 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25360 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25361 ])
25362 .unwrap();
25363 shared
25364 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25365 .unwrap();
25366 let address = SessionAddress {
25367 workspace_id,
25368 session: SessionKey {
25369 instance_id: AgentInstanceId(72),
25370 generation: SessionGeneration::default(),
25371 },
25372 };
25373 shared.bind_session_with_policy(
25374 &address,
25375 ProviderRuntimePolicy::raw_pty(),
25376 None,
25377 );
25378 shared
25379 .handle
25380 .dispatch(shared.prepare_command(ControlCommand::Register {
25381 instance_id: address.session.instance_id,
25382 agent_id: agent("claude"),
25383 transport: TransportKind::Pty,
25384 }))
25385 .unwrap();
25386 runtime.tick().await;
25387 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25388 let revision_before = shared.handle.snapshot().revision;
25389 let history_sequence_before = shared.current_sequence();
25390 let terminal_size = gate4agent_types::TerminalSize {
25391 rows: 24,
25392 columns: 80,
25393 };
25394
25395 for request in [
25396 NodeRequest::Prompt {
25397 session: address.clone(),
25398 text: "prompt".to_owned(),
25399 },
25400 NodeRequest::Paste {
25401 session: address.clone(),
25402 text: "paste".to_owned(),
25403 },
25404 NodeRequest::Resume {
25410 session: address.clone(),
25411 terminal_size,
25412 initial_prompt: Some("resume".to_owned()),
25413 },
25414 ] {
25415 let error = process_request_inner(
25416 &shared,
25417 77,
25418 ClientRole::Operator,
25419 request,
25420 )
25421 .await
25422 .unwrap_err();
25423 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25424 }
25425
25426 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25427 assert_eq!(shared.handle.snapshot().revision, revision_before);
25428 assert_eq!(shared.current_sequence(), history_sequence_before);
25429 let bindings = shared
25430 .session_bindings
25431 .lock()
25432 .unwrap_or_else(|poisoned| poisoned.into_inner());
25433 let binding = bindings.get(&address.session.instance_id).unwrap();
25434 assert_eq!(binding.generation, address.session.generation);
25435 assert!(binding.pending_resume.is_none());
25436 assert!(binding.record_id.is_none());
25437 }
25438
25439 #[tokio::test]
25440 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25441 let catalog = active_registry().unwrap();
25442 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25443 let primary = WorkspaceConfig::new(
25444 WorkspaceId::new("primary").unwrap(),
25445 std::env::current_dir().unwrap(),
25446 )
25447 .unwrap();
25448 let shared = NodeShared::new(
25449 handle,
25450 "fixture-token".to_owned(),
25451 NodeId::new("node-1").unwrap(),
25452 vec![primary],
25453 vec![agent("claude")],
25454 );
25455 shared
25456 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25457 .unwrap();
25458 let before = shared.snapshot();
25459 let error = process_request_inner(
25460 &shared,
25461 77,
25462 ClientRole::Operator,
25463 NodeRequest::RegisterWorkspace {
25464 workspace_id: WorkspaceId::new("foreign").unwrap(),
25465 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25466 },
25467 )
25468 .await
25469 .unwrap_err();
25470 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25471 assert_eq!(error.message, "invalid-request");
25472 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25473 }
25474
25475 #[tokio::test]
25476 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25477 let catalog = active_registry().unwrap();
25478 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25479 let primary_id = WorkspaceId::new("primary").unwrap();
25480 let primary = WorkspaceConfig::new(
25481 primary_id.clone(),
25482 std::env::current_dir().unwrap(),
25483 )
25484 .unwrap();
25485 let shared = NodeShared::new(
25486 handle,
25487 "fixture-token".to_owned(),
25488 NodeId::new("node-1").unwrap(),
25489 vec![primary.clone()],
25490 vec![agent("claude")],
25491 );
25492 let secondary_id = WorkspaceId::new("secondary").unwrap();
25493 let secondary = shared
25494 .register_workspace(
25495 secondary_id.clone(),
25496 std::env::temp_dir().to_string_lossy().into_owned(),
25497 )
25498 .await
25499 .unwrap();
25500 assert_eq!(secondary.workspace_id, secondary_id);
25501 assert!(secondary.sessions.is_empty());
25502 assert_eq!(shared.snapshot().workspaces.len(), 2);
25503
25504 let duplicate_id = shared
25505 .register_workspace(
25506 secondary_id.clone(),
25507 primary.canonical_root().to_owned(),
25508 )
25509 .await
25510 .unwrap_err();
25511 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25512 let duplicate_root = shared
25513 .register_workspace(
25514 WorkspaceId::new("duplicate-root").unwrap(),
25515 windows_path_text(&secondary.canonical_root).to_owned(),
25516 )
25517 .await
25518 .unwrap_err();
25519 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25520
25521 let address = SessionAddress {
25522 workspace_id: secondary_id.clone(),
25523 session: SessionKey {
25524 instance_id: AgentInstanceId(41),
25525 generation: SessionGeneration(1),
25526 },
25527 };
25528 shared.bind_session(
25529 address.session.instance_id,
25530 secondary_id.clone(),
25531 address.session.generation,
25532 );
25533 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25534 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25535 shared.remove_binding(&address);
25536 shared.unregister_workspace(&secondary_id).unwrap();
25537 assert_eq!(shared.snapshot().workspaces.len(), 1);
25538
25539 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25540 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25541 let history = shared
25542 .history
25543 .lock()
25544 .unwrap_or_else(|poisoned| poisoned.into_inner());
25545 assert!(matches!(
25546 &history.events[0],
25547 NodeEventEnvelope {
25548 sequence: 1,
25549 event: NodeEvent::WorkspaceAdded { workspace },
25550 } if workspace.workspace_id == secondary_id
25551 ));
25552 assert!(matches!(
25553 &history.events[1],
25554 NodeEventEnvelope {
25555 sequence: 2,
25556 event: NodeEvent::WorkspaceRemoved { workspace_id },
25557 } if workspace_id == &secondary_id
25558 ));
25559 }
25560
25561 #[tokio::test]
25562 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25563 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25564 std::fs::create_dir_all(&secondary_root).unwrap();
25565 let catalog = active_registry().unwrap();
25566 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25567 let primary_id = WorkspaceId::new("primary").unwrap();
25568 let secondary_id = WorkspaceId::new("secondary").unwrap();
25569 let shared = NodeShared::new(
25570 handle,
25571 "fixture-token".to_owned(),
25572 NodeId::new("node-session-reference-index").unwrap(),
25573 vec![
25574 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25575 .unwrap(),
25576 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25577 ],
25578 vec![agent("claude"), agent("codex")],
25579 );
25580 let identity = ProviderSessionIdentity {
25581 key: gate4agent_types::ProviderSessionKey::SessionId,
25582 id: "provider-session-42".to_owned(),
25583 transcript_path: None,
25584 };
25585 let index_request = |workspace_id: WorkspaceId,
25586 provider: AgentId,
25587 identity: ProviderSessionIdentity,
25588 display_name: &str| {
25589 NodeRequest::IndexProviderSession {
25590 workspace_id,
25591 provider,
25592 identity,
25593 display_name: display_name.to_owned(),
25594 }
25595 };
25596 let request = index_request(
25597 primary_id.clone(),
25598 agent("claude"),
25599 identity.clone(),
25600 "release shepherd",
25601 );
25602 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25603 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25604 }));
25605 let observer = process_request_inner(
25606 &shared,
25607 77,
25608 ClientRole::Observer,
25609 request.clone(),
25610 )
25611 .await
25612 .unwrap_err();
25613 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25614 let uncontrolled = process_request_inner(
25615 &shared,
25616 77,
25617 ClientRole::Operator,
25618 request.clone(),
25619 )
25620 .await
25621 .unwrap_err();
25622 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25623 shared
25624 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25625 .unwrap();
25626
25627 let indexed = process_request_inner(
25628 &shared,
25629 77,
25630 ClientRole::Operator,
25631 request.clone(),
25632 )
25633 .await
25634 .unwrap();
25635 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25636 panic!("provider session reference index returned another response");
25637 };
25638 assert_eq!(record.state, ManagedSessionState::Dormant);
25639 assert_eq!(record.mode, SessionMode::Pty);
25640 assert_eq!(record.workspace_id, primary_id);
25641 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25642 assert!(record.active_session.is_none());
25643 assert!(record.environment_profile.is_none());
25644 assert!(record.bundle.is_none());
25645 assert!(record.context.is_none());
25646
25647 let repeated = process_request_inner(
25648 &shared,
25649 77,
25650 ClientRole::Operator,
25651 index_request(
25652 primary_id.clone(),
25653 agent("claude"),
25654 identity.clone(),
25655 "ignored replacement",
25656 ),
25657 )
25658 .await
25659 .unwrap();
25660 assert!(matches!(
25661 repeated,
25662 NodeResponse::ProviderSessionIndexed { record: ref existing }
25663 if existing.record_id == record.record_id
25664 && existing.display_name == "release shepherd"
25665 ));
25666 let conflicting_workspace = process_request_inner(
25667 &shared,
25668 77,
25669 ClientRole::Operator,
25670 index_request(
25671 secondary_id,
25672 agent("claude"),
25673 identity.clone(),
25674 "release shepherd",
25675 ),
25676 )
25677 .await
25678 .unwrap_err();
25679 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25680 let conflicting_provider = process_request_inner(
25681 &shared,
25682 77,
25683 ClientRole::Operator,
25684 index_request(
25685 primary_id.clone(),
25686 agent("codex"),
25687 identity.clone(),
25688 "release shepherd",
25689 ),
25690 )
25691 .await
25692 .unwrap_err();
25693 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25694 let transcript_rejected = process_request_inner(
25695 &shared,
25696 77,
25697 ClientRole::Operator,
25698 index_request(
25699 primary_id,
25700 agent("claude"),
25701 ProviderSessionIdentity {
25702 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25703 ..identity
25704 },
25705 "release shepherd",
25706 ),
25707 )
25708 .await
25709 .unwrap_err();
25710 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25711 let upserts = shared
25712 .history
25713 .lock()
25714 .unwrap_or_else(|poisoned| poisoned.into_inner())
25715 .events
25716 .iter()
25717 .filter(|event| matches!(
25718 &event.event,
25719 NodeEvent::SessionRecordUpserted { record: published }
25720 if published.record_id == record.record_id
25721 ))
25722 .count();
25723 assert_eq!(upserts, 1);
25724 std::fs::remove_dir_all(secondary_root).unwrap();
25725 }
25726
25727 #[tokio::test]
25728 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25729 let fixture_root = temporary_workspace_root("standalone-independent");
25730 let target = fixture_root.join("independent");
25731 std::fs::create_dir_all(&fixture_root).unwrap();
25732 let catalog = active_registry().unwrap();
25733 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25734 let selected = WorkspaceConfig::new(
25735 WorkspaceId::new("primary").unwrap(),
25736 std::env::current_dir().unwrap(),
25737 )
25738 .unwrap();
25739 let selected_root = selected.canonical_root().to_owned();
25740 let shared = NodeShared::new(
25741 handle,
25742 "fixture-token".to_owned(),
25743 NodeId::new("node-standalone").unwrap(),
25744 vec![selected],
25745 vec![agent("claude")],
25746 );
25747 let workspace_id = WorkspaceId::new("independent").unwrap();
25748 let request = NodeRequest::CreateStandaloneWorkspace {
25749 workspace_id: workspace_id.clone(),
25750 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25751 initial_branch: Some("main".to_owned()),
25752 };
25753 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25754 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25755 }));
25756 assert!(!request_uses_unnegotiated_capability(
25757 &request,
25758 &baseline_capabilities().unwrap(),
25759 ));
25760 assert!(request_uses_unnegotiated_capability(&request, &[]));
25761
25762 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25763 .await
25764 .unwrap_err();
25765 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25766 assert!(!target.exists());
25767 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25768 .await
25769 .unwrap_err();
25770 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25771 assert!(!target.exists());
25772 shared
25773 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25774 .unwrap();
25775
25776 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25777 .await
25778 .unwrap();
25779 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25780 panic!("standalone workspace creation returned another response");
25781 };
25782 assert_eq!(workspace.workspace_id, workspace_id);
25783 assert!(target.join(".git").is_dir());
25784 let branch = run_git_bounded(
25785 target.to_str().unwrap(),
25786 &["branch", "--show-current"],
25787 GIT_OUTPUT_MAX_BYTES,
25788 )
25789 .await
25790 .unwrap();
25791 assert!(branch.success);
25792 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25793 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25794 assert_eq!(worktrees.len(), 1);
25795 let listed_root = WorkspaceConfig::new(
25796 WorkspaceId::new("listed-independent").unwrap(),
25797 &worktrees[0].path,
25798 )
25799 .unwrap();
25800 assert!(platform::roots_equal(
25801 listed_root.canonical_root(),
25802 windows_path_text(&workspace.canonical_root),
25803 ));
25804 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25805 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25806 snapshot.workspace_id == workspace_id
25807 && snapshot.canonical_root == workspace.canonical_root
25808 }));
25809 assert!(shared
25810 .history
25811 .lock()
25812 .unwrap_or_else(|poisoned| poisoned.into_inner())
25813 .events
25814 .iter()
25815 .any(|event| matches!(
25816 &event.event,
25817 NodeEvent::WorkspaceAdded { workspace }
25818 if workspace.workspace_id == workspace_id
25819 )));
25820 std::fs::remove_dir_all(fixture_root).unwrap();
25821 }
25822
25823 #[tokio::test]
25824 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25825 let fixture_root = temporary_workspace_root("standalone-compensation");
25826 let created_target = fixture_root.join("created-target");
25827 let existing_target = fixture_root.join("existing-target");
25828 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25829 let invalid_state_path = invalid_state_parent.join("state.json");
25830 std::fs::create_dir_all(&existing_target).unwrap();
25831 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25832 let catalog = active_registry().unwrap();
25833 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25834 let primary = WorkspaceConfig::new(
25835 WorkspaceId::new("primary").unwrap(),
25836 std::env::current_dir().unwrap(),
25837 )
25838 .unwrap();
25839 let mut shared = NodeShared::new(
25840 handle,
25841 "fixture-token".to_owned(),
25842 NodeId::new("node-standalone-compensation").unwrap(),
25843 vec![primary],
25844 vec![agent("claude")],
25845 );
25846 shared.state_path = Some(invalid_state_path);
25847
25848 let created_recovery = shared
25849 .create_standalone_workspace(
25850 WorkspaceId::new("compensated").unwrap(),
25851 created_target.to_string_lossy().into_owned(),
25852 Some("main".to_owned()),
25853 )
25854 .await
25855 .unwrap_err();
25856 assert_eq!(
25857 created_recovery.code,
25858 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25859 );
25860 assert!(created_target.join(".git").is_dir());
25861 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25862 workspace.workspace_id.as_str() != "compensated"
25863 }));
25864
25865 let existing_recovery = shared
25866 .create_standalone_workspace(
25867 WorkspaceId::new("restored").unwrap(),
25868 existing_target.to_string_lossy().into_owned(),
25869 None,
25870 )
25871 .await
25872 .unwrap_err();
25873 assert_eq!(
25874 existing_recovery.code,
25875 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25876 );
25877 assert!(existing_target.is_dir());
25878 assert!(existing_target.join(".git").is_dir());
25879 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25880 workspace.workspace_id.as_str() != "restored"
25881 }));
25882
25883 let registered_empty = fixture_root.join("registered-empty");
25884 std::fs::create_dir(®istered_empty).unwrap();
25885 let catalog = active_registry().unwrap();
25886 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25887 let registered = WorkspaceConfig::new(
25888 WorkspaceId::new("registered-empty").unwrap(),
25889 ®istered_empty,
25890 )
25891 .unwrap();
25892 let duplicate_shared = NodeShared::new(
25893 handle,
25894 "fixture-token".to_owned(),
25895 NodeId::new("node-standalone-duplicate-root").unwrap(),
25896 vec![registered],
25897 vec![agent("claude")],
25898 );
25899 let duplicate = duplicate_shared
25900 .create_standalone_workspace(
25901 WorkspaceId::new("duplicate-root").unwrap(),
25902 registered_empty.to_string_lossy().into_owned(),
25903 None,
25904 )
25905 .await
25906 .unwrap_err();
25907 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
25908 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
25909 std::fs::remove_dir_all(fixture_root).unwrap();
25910 }
25911
25912 #[test]
25913 fn managed_record_id_collision_preserves_the_existing_record() {
25914 let catalog = active_registry().unwrap();
25915 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25916 let workspace = WorkspaceConfig::new(
25917 WorkspaceId::new("primary").unwrap(),
25918 std::env::current_dir().unwrap(),
25919 )
25920 .unwrap();
25921 let shared = NodeShared::new(
25922 handle,
25923 "fixture-token".to_owned(),
25924 NodeId::new("node-1").unwrap(),
25925 vec![workspace.clone()],
25926 vec![agent("claude")],
25927 );
25928 let record_id = SessionRecordId::new("sr-collision").unwrap();
25929 let original = ManagedSessionRecord {
25930 record_id: record_id.clone(),
25931 display_name: "original".to_owned(),
25932 provider: agent("claude"),
25933 mode: SessionMode::Pty,
25934 state: ManagedSessionState::Dormant,
25935 workspace_id: workspace.workspace_id,
25936 canonical_root: opaque_windows_path(workspace.canonical_root),
25937 provider_session: None,
25938 active_session: None,
25939 environment_profile: None,
25940 bundle: None,
25941 context_id: None,
25942 context: None,
25943 exported_context: None,
25944 task_binding: None,
25945 created_at_unix_ms: 1,
25946 updated_at_unix_ms: 1,
25947 last_error: None,
25948 };
25949 shared.insert_record(original.clone()).unwrap();
25950 let mut collision = original.clone();
25951 collision.display_name = "replacement".to_owned();
25952 let error = shared.insert_record(collision).unwrap_err();
25953 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
25954 assert_eq!(shared.record(&record_id).unwrap(), original);
25955 }
25956
25957 #[test]
25958 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
25959 let catalog = active_registry().unwrap();
25960 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25961 let workspace = WorkspaceConfig::new(
25962 WorkspaceId::new("primary").unwrap(),
25963 std::env::current_dir().unwrap(),
25964 )
25965 .unwrap();
25966 let shared = NodeShared::new(
25967 handle,
25968 "fixture-token".to_owned(),
25969 NodeId::new("node-1").unwrap(),
25970 vec![workspace.clone()],
25971 vec![agent("claude")],
25972 );
25973 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
25974 shared
25975 .insert_record(ManagedSessionRecord {
25976 record_id: record_id.clone(),
25977 display_name: "runtime error".to_owned(),
25978 provider: agent("claude"),
25979 mode: SessionMode::Pty,
25980 state: ManagedSessionState::IdentityPending,
25981 workspace_id: workspace.workspace_id,
25982 canonical_root: opaque_windows_path(workspace.canonical_root),
25983 provider_session: None,
25984 active_session: None,
25985 environment_profile: None,
25986 bundle: None,
25987 context_id: None,
25988 context: None,
25989 exported_context: None,
25990 task_binding: None,
25991 created_at_unix_ms: 1,
25992 updated_at_unix_ms: 1,
25993 last_error: None,
25994 })
25995 .unwrap();
25996
25997 shared
25998 .mark_record_error(
25999 &record_id,
26000 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26001 )
26002 .unwrap();
26003 let record = shared.record(&record_id).unwrap();
26004 assert_eq!(record.state, ManagedSessionState::Unavailable);
26005 assert_eq!(
26006 record.last_error.as_deref(),
26007 Some("provider-runtime-failed"),
26008 );
26009 let snapshot = shared.snapshot();
26010 assert_eq!(snapshot.session_records, vec![record]);
26011 }
26012
26013 #[test]
26022 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26023 let catalog = active_registry().unwrap();
26024 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26025 let workspace = WorkspaceConfig::new(
26026 WorkspaceId::new("primary").unwrap(),
26027 std::env::current_dir().unwrap(),
26028 )
26029 .unwrap();
26030 let shared = NodeShared::new(
26031 handle,
26032 "fixture-token".to_owned(),
26033 NodeId::new("node-1").unwrap(),
26034 vec![workspace],
26035 vec![agent("claude")],
26036 );
26037
26038 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26039
26040 shared.record_rejected_command(
26041 CommandId(1),
26042 "agent 'claude' does not support transport Acp".to_owned(),
26043 true,
26044 );
26045 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26047
26048 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26049 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26050 assert!(reason.unsupported_transport);
26051
26052 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26054
26055 shared.record_rejected_command(
26060 CommandId(3),
26061 "backend busy".to_owned(),
26062 false,
26063 );
26064 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26065 assert!(!unclassified.unsupported_transport);
26066 }
26067
26068 #[test]
26069 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26070 let catalog = active_registry().unwrap();
26071 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26072 let workspace = WorkspaceConfig::new(
26073 WorkspaceId::new("primary").unwrap(),
26074 std::env::current_dir().unwrap(),
26075 )
26076 .unwrap();
26077 let shared = NodeShared::new(
26078 handle,
26079 "fixture-token".to_owned(),
26080 NodeId::new("node-identity-policy").unwrap(),
26081 vec![workspace.clone()],
26082 vec![agent("claude")],
26083 );
26084 let address = SessionAddress {
26085 workspace_id: workspace.workspace_id.clone(),
26086 session: SessionKey {
26087 instance_id: AgentInstanceId(90),
26088 generation: SessionGeneration(1),
26089 },
26090 };
26091 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26092 shared
26093 .insert_record(ManagedSessionRecord {
26094 record_id: record_id.clone(),
26095 display_name: "policy denied".to_owned(),
26096 provider: agent("claude"),
26097 mode: SessionMode::Pty,
26098 state: ManagedSessionState::IdentityPending,
26099 workspace_id: workspace.workspace_id,
26100 canonical_root: opaque_windows_path(workspace.canonical_root),
26101 provider_session: None,
26102 active_session: Some(address.clone()),
26103 environment_profile: None,
26104 bundle: None,
26105 context_id: None,
26106 context: None,
26107 exported_context: None,
26108 task_binding: None,
26109 created_at_unix_ms: 1,
26110 updated_at_unix_ms: 1,
26111 last_error: None,
26112 })
26113 .unwrap();
26114 let event = ControlEvent {
26115 sequence: 1,
26116 command_id: None,
26117 instance_id: address.session.instance_id,
26118 generation: address.session.generation,
26119 event: ControlEventKind::ProviderEvent {
26120 sequence: 1,
26121 source: gate4agent_types::ProviderSource {
26122 family: AdapterFamily::PtySemantic,
26123 binding: AdapterBinding::new(
26124 gate4agent_types::AdapterId::new("claude").unwrap(),
26125 "fixture/v1",
26126 gate4agent_types::AdapterVerification::SyntheticFixture,
26127 )
26128 .unwrap(),
26129 },
26130 source_sequence: 1,
26131 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26132 identity: gate4agent_types::ProviderSessionIdentity {
26133 key: gate4agent_types::ProviderSessionKey::SessionId,
26134 id: "provider-session".to_owned(),
26135 transcript_path: None,
26136 },
26137 },
26138 },
26139 };
26140
26141 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26142
26143 let record = shared.record(&record_id).unwrap();
26144 assert!(record.provider_session.is_none());
26145 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26146 assert_eq!(record.active_session.as_ref(), Some(&address));
26147 }
26148
26149 #[test]
26150 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26151 let secondary_root = temporary_workspace_root("identity-scope");
26152 std::fs::create_dir_all(&secondary_root).unwrap();
26153 let catalog = active_registry().unwrap();
26154 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26155 let primary = WorkspaceConfig::new(
26156 WorkspaceId::new("primary").unwrap(),
26157 std::env::current_dir().unwrap(),
26158 )
26159 .unwrap();
26160 let secondary = WorkspaceConfig::new(
26161 WorkspaceId::new("secondary").unwrap(),
26162 &secondary_root,
26163 )
26164 .unwrap();
26165 let shared = NodeShared::new(
26166 handle,
26167 "fixture-token".to_owned(),
26168 NodeId::new("node-1").unwrap(),
26169 vec![primary.clone(), secondary.clone()],
26170 vec![agent("claude")],
26171 );
26172 let identity = gate4agent_types::ProviderSessionIdentity {
26173 key: gate4agent_types::ProviderSessionKey::SessionId,
26174 id: "provider-session".to_owned(),
26175 transcript_path: None,
26176 };
26177 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26178 shared
26179 .insert_record(ManagedSessionRecord {
26180 record_id: dormant_id.clone(),
26181 display_name: "dormant".to_owned(),
26182 provider: agent("claude"),
26183 mode: SessionMode::Pty,
26184 state: ManagedSessionState::Dormant,
26185 workspace_id: primary.workspace_id,
26186 canonical_root: opaque_windows_path(primary.canonical_root),
26187 provider_session: Some(identity.clone()),
26188 active_session: None,
26189 environment_profile: None,
26190 bundle: None,
26191 context_id: None,
26192 context: None,
26193 exported_context: None,
26194 task_binding: None,
26195 created_at_unix_ms: 1,
26196 updated_at_unix_ms: 1,
26197 last_error: None,
26198 })
26199 .unwrap();
26200 let address = SessionAddress {
26201 workspace_id: secondary.workspace_id.clone(),
26202 session: SessionKey {
26203 instance_id: AgentInstanceId(91),
26204 generation: SessionGeneration(1),
26205 },
26206 };
26207 let current_id = SessionRecordId::new("sr-current").unwrap();
26208 shared
26209 .insert_record(ManagedSessionRecord {
26210 record_id: current_id.clone(),
26211 display_name: "current".to_owned(),
26212 provider: agent("claude"),
26213 mode: SessionMode::Pty,
26214 state: ManagedSessionState::IdentityPending,
26215 workspace_id: secondary.workspace_id,
26216 canonical_root: opaque_windows_path(secondary.canonical_root),
26217 provider_session: None,
26218 active_session: Some(address.clone()),
26219 environment_profile: None,
26220 bundle: None,
26221 context_id: None,
26222 context: None,
26223 exported_context: None,
26224 task_binding: None,
26225 created_at_unix_ms: 2,
26226 updated_at_unix_ms: 2,
26227 last_error: None,
26228 })
26229 .unwrap();
26230 shared.bind_managed_session(
26231 &address,
26232 current_id.clone(),
26233 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26234 None,
26235 );
26236 let event = ControlEvent {
26237 sequence: 1,
26238 command_id: None,
26239 instance_id: address.session.instance_id,
26240 generation: address.session.generation,
26241 event: ControlEventKind::ProviderEvent {
26242 sequence: 1,
26243 source: gate4agent_types::ProviderSource {
26244 family: gate4agent_types::AdapterFamily::PtySemantic,
26245 binding: gate4agent_types::AdapterBinding::new(
26246 gate4agent_types::AdapterId::new("claude").unwrap(),
26247 "fixture/v1",
26248 gate4agent_types::AdapterVerification::SyntheticFixture,
26249 )
26250 .unwrap(),
26251 },
26252 source_sequence: 1,
26253 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26254 identity,
26255 },
26256 },
26257 };
26258 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26259
26260 let binding = shared
26261 .session_bindings
26262 .lock()
26263 .unwrap_or_else(|poisoned| poisoned.into_inner())
26264 .get(&address.session.instance_id)
26265 .cloned()
26266 .unwrap();
26267 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26268 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26269 let current = shared.record(¤t_id).unwrap();
26270 assert_eq!(current.state, ManagedSessionState::Unavailable);
26271 assert_eq!(current.active_session.as_ref(), Some(&address));
26272 assert_eq!(
26273 current.last_error.as_deref(),
26274 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26275 );
26276 std::fs::remove_dir_all(secondary_root).unwrap();
26277 }
26278
26279 #[tokio::test]
26280 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26281 let root = temporary_workspace_root("semantic-provider-session");
26282 std::fs::create_dir_all(&root).unwrap();
26283 let state_path = root.join("state-v1.json");
26284 let catalog = active_registry().unwrap();
26285 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26286 let workspace = WorkspaceConfig::new(
26287 WorkspaceId::new("primary").unwrap(),
26288 &root,
26289 )
26290 .unwrap();
26291 let node_id = NodeId::new("node-semantic-identity").unwrap();
26292 let shared = NodeShared::new_with_incarnation(
26293 handle,
26294 "fixture-token".to_owned(),
26295 node_id.clone(),
26296 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26297 vec![workspace.clone()],
26298 vec![agent("claude")],
26299 ProviderRuntimeStatuses::default(),
26300 None,
26301 Vec::new(),
26302 Vec::new(),
26303 SpawnProfileRegistry::default(),
26304 None,
26305 None,
26306 None,
26307 Some(state_path.clone()),
26308 Vec::new(),
26309 Vec::new(),
26310 Vec::new(),
26311 Vec::new(),
26312 None,
26313 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26314 );
26315 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26316 key: gate4agent_types::ProviderSessionKey::SessionId,
26317 id: "provider-session".to_owned(),
26318 transcript_path: None,
26319 };
26320 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26321 shared
26322 .insert_record(ManagedSessionRecord {
26323 record_id: durable_id.clone(),
26324 display_name: "durable".to_owned(),
26325 provider: agent("claude"),
26326 mode: SessionMode::Pty,
26327 state: ManagedSessionState::Dormant,
26328 workspace_id: workspace.workspace_id.clone(),
26329 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26330 provider_session: Some(persisted_identity.clone()),
26331 active_session: None,
26332 environment_profile: None,
26333 bundle: None,
26334 context_id: None,
26335 context: None,
26336 exported_context: None,
26337 task_binding: None,
26338 created_at_unix_ms: 1,
26339 updated_at_unix_ms: 1,
26340 last_error: None,
26341 })
26342 .unwrap();
26343 let address = SessionAddress {
26344 workspace_id: workspace.workspace_id.clone(),
26345 session: SessionKey {
26346 instance_id: AgentInstanceId(92),
26347 generation: SessionGeneration(2),
26348 },
26349 };
26350 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26351 shared
26352 .insert_record(ManagedSessionRecord {
26353 record_id: pending_id.clone(),
26354 display_name: "pending".to_owned(),
26355 provider: agent("claude"),
26356 mode: SessionMode::Pty,
26357 state: ManagedSessionState::IdentityPending,
26358 workspace_id: workspace.workspace_id.clone(),
26359 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26360 provider_session: None,
26361 active_session: Some(address.clone()),
26362 environment_profile: None,
26363 bundle: None,
26364 context_id: None,
26365 context: None,
26366 exported_context: None,
26367 task_binding: None,
26368 created_at_unix_ms: 2,
26369 updated_at_unix_ms: 2,
26370 last_error: None,
26371 })
26372 .unwrap();
26373 shared.bind_managed_session(
26374 &address,
26375 pending_id.clone(),
26376 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26377 None,
26378 );
26379 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26380 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26381 ..persisted_identity
26382 };
26383 let event = ControlEvent {
26384 sequence: 1,
26385 command_id: None,
26386 instance_id: address.session.instance_id,
26387 generation: address.session.generation,
26388 event: ControlEventKind::ProviderEvent {
26389 sequence: 1,
26390 source: gate4agent_types::ProviderSource {
26391 family: gate4agent_types::AdapterFamily::PtySemantic,
26392 binding: gate4agent_types::AdapterBinding::new(
26393 gate4agent_types::AdapterId::new("claude").unwrap(),
26394 "fixture/v1",
26395 gate4agent_types::AdapterVerification::SyntheticFixture,
26396 )
26397 .unwrap(),
26398 },
26399 source_sequence: 1,
26400 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26401 identity: observed_identity.clone(),
26402 },
26403 },
26404 };
26405 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26406
26407 let snapshot = shared.snapshot();
26408 assert_eq!(snapshot.session_records.len(), 1);
26409 let live = &snapshot.session_records[0];
26410 assert_eq!(live.record_id, durable_id);
26411 assert_eq!(live.state, ManagedSessionState::Live);
26412 assert_eq!(live.active_session.as_ref(), Some(&address));
26413 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26414 let binding = shared
26415 .session_bindings
26416 .lock()
26417 .unwrap_or_else(|poisoned| poisoned.into_inner())
26418 .get(&address.session.instance_id)
26419 .cloned()
26420 .unwrap();
26421 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26422
26423 let mut duplicate = live.clone();
26424 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26425 duplicate.display_name = "duplicate".to_owned();
26426 duplicate.state = ManagedSessionState::Dormant;
26427 duplicate.active_session = None;
26428 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26429 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26430 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26431 assert_eq!(shared.snapshot().session_records.len(), 1);
26432
26433 let terminal_size = gate4agent_types::TerminalSize {
26434 rows: 24,
26435 columns: 80,
26436 };
26437 let (first_busy, second_busy) = tokio::join!(
26438 shared.resume_session_record(&durable_id, terminal_size, None),
26439 shared.resume_session_record(&durable_id, terminal_size, None),
26440 );
26441 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26442 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26443 assert_eq!(busy.message, "session-record-busy");
26444 }
26445
26446 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26447 assert_eq!(loaded.records.len(), 1);
26448 assert_eq!(loaded.records[0].record_id, durable_id);
26449 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26450 assert_eq!(
26451 loaded.records[0]
26452 .provider_session
26453 .as_ref()
26454 .unwrap()
26455 .transcript_path,
26456 None,
26457 );
26458 let catalog = active_registry().unwrap();
26459 let (reloaded_handle, _reloaded_runtime) =
26460 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26461 let reloaded = NodeShared::new_with_incarnation(
26462 reloaded_handle,
26463 "fixture-token".to_owned(),
26464 node_id,
26465 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26466 vec![workspace],
26467 vec![agent("claude")],
26468 ProviderRuntimeStatuses::default(),
26469 None,
26470 Vec::new(),
26471 Vec::new(),
26472 SpawnProfileRegistry::default(),
26473 None,
26474 None,
26475 None,
26476 None,
26477 loaded.records,
26478 loaded.managed_worktrees,
26479 loaded.managed_worktree_tombstones,
26480 loaded.materializations,
26481 loaded.warning,
26482 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26483 );
26484 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26485 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26486 std::fs::remove_dir_all(root).unwrap();
26487 }
26488
26489 #[tokio::test]
26490 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26491 let secondary_root = temporary_workspace_root("workspace-restore");
26492 let other_root = temporary_workspace_root("workspace-conflict");
26493 std::fs::create_dir_all(&secondary_root).unwrap();
26494 std::fs::create_dir_all(&other_root).unwrap();
26495 let catalog = active_registry().unwrap();
26496 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26497 let primary = WorkspaceConfig::new(
26498 WorkspaceId::new("primary").unwrap(),
26499 std::env::current_dir().unwrap(),
26500 )
26501 .unwrap();
26502 let secondary = WorkspaceConfig::new(
26503 WorkspaceId::new("secondary").unwrap(),
26504 &secondary_root,
26505 )
26506 .unwrap();
26507 let shared = NodeShared::new(
26508 handle,
26509 "fixture-token".to_owned(),
26510 NodeId::new("node-1").unwrap(),
26511 vec![primary, secondary.clone()],
26512 vec![agent("claude")],
26513 );
26514 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26515 shared
26516 .insert_record(ManagedSessionRecord {
26517 record_id: record_id.clone(),
26518 display_name: "workspace record".to_owned(),
26519 provider: agent("claude"),
26520 mode: SessionMode::Pty,
26521 state: ManagedSessionState::Dormant,
26522 workspace_id: secondary.workspace_id.clone(),
26523 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26524 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26525 key: gate4agent_types::ProviderSessionKey::SessionId,
26526 id: "provider-session".to_owned(),
26527 transcript_path: None,
26528 }),
26529 active_session: None,
26530 environment_profile: None,
26531 bundle: None,
26532 context_id: None,
26533 context: None,
26534 exported_context: None,
26535 task_binding: None,
26536 created_at_unix_ms: 1,
26537 updated_at_unix_ms: 1,
26538 last_error: None,
26539 })
26540 .unwrap();
26541 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26542 let unavailable = shared.record(&record_id).unwrap();
26543 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26544 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26545
26546 let conflict = shared
26547 .register_workspace(
26548 secondary.workspace_id.clone(),
26549 other_root.to_string_lossy().into_owned(),
26550 )
26551 .await
26552 .unwrap_err();
26553 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26554 shared
26555 .register_workspace(
26556 secondary.workspace_id,
26557 secondary_root.to_string_lossy().into_owned(),
26558 )
26559 .await
26560 .unwrap();
26561 let restored = shared.record(&record_id).unwrap();
26562 assert_eq!(restored.state, ManagedSessionState::Dormant);
26563 assert!(restored.last_error.is_none());
26564 std::fs::remove_dir_all(secondary_root).unwrap();
26565 std::fs::remove_dir_all(other_root).unwrap();
26566 }
26567
26568 #[test]
26569 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26570 let error = validate_workspace_request_root(
26571 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26572 )
26573 .unwrap_err();
26574 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26575 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26576 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26577 }
26578
26579 #[test]
26580 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26581 let catalog = active_registry().unwrap();
26582 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26583 let workspace = WorkspaceConfig::new(
26584 WorkspaceId::new("primary").unwrap(),
26585 std::env::current_dir().unwrap(),
26586 )
26587 .unwrap();
26588 let shared = NodeShared::new(
26589 handle,
26590 "fixture-token".to_owned(),
26591 NodeId::new("node-1").unwrap(),
26592 vec![workspace],
26593 vec![agent("claude")],
26594 );
26595 let publishers = 4_u64;
26596 let events_per_publisher = 256_u64;
26597 let start = std::sync::Barrier::new(publishers as usize + 1);
26598 let finished = AtomicU64::new(0);
26599
26600 std::thread::scope(|scope| {
26601 for _ in 0..publishers {
26602 scope.spawn(|| {
26603 start.wait();
26604 for _ in 0..events_per_publisher {
26605 shared.publish(NodeEvent::ControllerChanged { controller: None });
26606 }
26607 finished.fetch_add(1, Ordering::Release);
26608 });
26609 }
26610 start.wait();
26611 while finished.load(Ordering::Acquire) != publishers {
26612 assert_resync_history_is_linearized(shared.resync(0));
26613 std::thread::yield_now();
26614 }
26615 });
26616 let response = shared.resync(0);
26617 assert_resync_history_is_linearized(response.clone());
26618 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26619 unreachable!("resync helper returned another response");
26620 };
26621 assert_eq!(event_sequence, publishers * events_per_publisher);
26622 assert_eq!(events.len() as u64, event_sequence);
26623 }
26624
26625 fn assert_resync_history_is_linearized(response: NodeResponse) {
26626 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26627 unreachable!("resync helper returned another response");
26628 };
26629 assert!(events.windows(2).all(|pair| {
26630 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26631 }));
26632 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26633 }
26634
26635 #[test]
26636 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26637 assert_eq!(
26638 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26639 r"C:\repo\worktree",
26640 );
26641 assert_eq!(
26642 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26643 r"\\server\share\repo",
26644 );
26645 assert!(validate_workspace_root(
26646 &WorkspaceId::new("network").unwrap(),
26647 r"\\server\share\repo",
26648 )
26649 .is_err());
26650 let workspace = WorkspaceConfig::new(
26651 WorkspaceId::new("primary").unwrap(),
26652 std::env::current_dir().unwrap(),
26653 )
26654 .unwrap();
26655 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26656 }
26657
26658 #[test]
26659 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26660 let catalog = active_registry().unwrap();
26661 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26662 let workspace_id = WorkspaceId::new("primary").unwrap();
26663 let workspace = WorkspaceConfig::new(
26664 workspace_id.clone(),
26665 std::env::current_dir().unwrap(),
26666 )
26667 .unwrap();
26668 let shared = NodeShared::new(
26669 handle,
26670 "fixture-token".to_owned(),
26671 NodeId::new("node-1").unwrap(),
26672 vec![workspace],
26673 vec![agent("claude")],
26674 );
26675 let instance_id = AgentInstanceId(17);
26676 let original = SessionAddress {
26677 workspace_id,
26678 session: SessionKey {
26679 instance_id,
26680 generation: SessionGeneration(3),
26681 },
26682 };
26683 shared.bind_session(
26684 instance_id,
26685 original.workspace_id.clone(),
26686 original.session.generation,
26687 );
26688 shared
26689 .arm_resume(
26690 &original,
26691 CommandId(41),
26692 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26693 )
26694 .unwrap();
26695
26696 shared.publish_control(ControlEvent {
26697 sequence: 1,
26698 command_id: None,
26699 instance_id,
26700 generation: SessionGeneration(4),
26701 event: ControlEventKind::Running { process_id: None },
26702 });
26703 assert_eq!(
26704 shared.address_for(instance_id, SessionGeneration(3)),
26705 Some(original.clone()),
26706 );
26707 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26708
26709 shared.publish_control(ControlEvent {
26710 sequence: 2,
26711 command_id: Some(CommandId(42)),
26712 instance_id,
26713 generation: SessionGeneration(3),
26714 event: ControlEventKind::CommandRejected {
26715 message: "unrelated command".to_owned(),
26716 },
26717 });
26718
26719 shared.publish_control(ControlEvent {
26720 sequence: 3,
26721 command_id: None,
26722 instance_id,
26723 generation: SessionGeneration(4),
26724 event: ControlEventKind::ResumeAuthorized {
26725 session: gate4agent_types::ResumeSessionSummary {
26726 key: gate4agent_types::ProviderSessionKey::SessionId,
26727 id: "fixture-session".to_owned(),
26728 },
26729 },
26730 });
26731 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26732 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26733 assert_eq!(rebound.workspace_id, original.workspace_id);
26734 let history = shared
26735 .history
26736 .lock()
26737 .unwrap_or_else(|poisoned| poisoned.into_inner());
26738 assert!(history.events.iter().any(|envelope| matches!(
26739 &envelope.event,
26740 NodeEvent::Control { address, event }
26741 if address == &rebound
26742 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26743 )));
26744 }
26745
26746 #[test]
26747 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26748 for agent in ["claude", "codex", "kimi"] {
26749 assert_eq!(
26750 prompt_framing(&AgentId::new(agent).unwrap()),
26751 PromptFraming::BracketedPaste
26752 );
26753 }
26754 }
26755
26756 fn generous_walk_deadline() -> Instant {
26757 Instant::now() + Duration::from_secs(30)
26758 }
26759
26760 #[test]
26761 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26762 let root = temporary_workspace_root("tree");
26763 std::fs::create_dir_all(root.join("src")).unwrap();
26764 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26765 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26766 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26767 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26768 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26769 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26775 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26776 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26780 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26781
26782 let (entries, truncated, _budget) =
26783 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26784 assert!(!truncated);
26785 assert!(entries.iter().any(|entry| {
26786 entry.relative_path.as_utf8() == Some("src")
26787 && entry.kind == WorkspaceEntryKind::Directory
26788 }));
26789 assert!(entries.iter().any(|entry| {
26790 entry.relative_path.as_utf8() == Some("src/lib.rs")
26791 && entry.kind == WorkspaceEntryKind::File
26792 }));
26793 assert!(entries.iter().any(|entry| {
26794 entry.relative_path.as_utf8() == Some("target-notes")
26795 && entry.kind == WorkspaceEntryKind::Directory
26796 }));
26797 assert!(entries.iter().any(|entry| {
26798 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26799 && entry.kind == WorkspaceEntryKind::File
26800 }));
26801 assert!(entries.iter().all(|entry| {
26802 let path = entry.relative_path.as_utf8().unwrap();
26803 !path.starts_with(".git")
26804 && !(path == "target" || path.starts_with("target/"))
26805 && !(path == "target-a2" || path.starts_with("target-a2/"))
26806 && !path.starts_with("node_modules")
26807 }));
26808
26809 std::fs::remove_dir_all(&root).unwrap();
26810 }
26811
26812 #[test]
26813 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26814 let root = temporary_workspace_root("tree-entry-cap");
26815 std::fs::create_dir_all(&root).unwrap();
26816 for index in 0..20 {
26817 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26818 }
26819
26820 let (entries, truncated, budget) =
26821 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26822 assert!(truncated);
26823 assert!(budget.entry_cap_exceeded);
26824 assert!(!budget.time_budget_exceeded);
26825 assert!(entries.len() <= 5);
26826
26827 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26828 .expect("an entry-cap-truncated walk must report a truncation marker");
26829 assert!(inspection_truncation.walk_entry_cap_exceeded);
26830 assert!(!inspection_truncation.walk_time_budget_exceeded);
26831 assert!(!inspection_truncation.git_time_budget_exceeded);
26832 assert!(inspection_truncation.entries_visited >= 5);
26833
26834 std::fs::remove_dir_all(&root).unwrap();
26835 }
26836
26837 #[test]
26838 fn workspace_walk_within_budget_reports_no_truncation() {
26839 let root = temporary_workspace_root("tree-no-truncation");
26840 std::fs::create_dir_all(&root).unwrap();
26841 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26842
26843 let (_entries, truncated, budget) =
26844 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26845 assert!(!truncated);
26846 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26847
26848 std::fs::remove_dir_all(&root).unwrap();
26849 }
26850
26851 #[test]
26852 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26853 let root = temporary_workspace_root("tree-prefix-siblings");
26854 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26855 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26856 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26857 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26858
26859 let (entries, truncated, _budget) =
26860 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26861 assert!(!truncated);
26862 let paths = entries
26863 .iter()
26864 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26865 .collect::<Vec<_>>();
26866 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26867 assert_eq!(
26868 paths,
26869 vec![
26870 "gate4agent-c2".to_owned(),
26871 "gate4agent-c2-client".to_owned(),
26872 "gate4agent-c2-client/lib.rs".to_owned(),
26873 "gate4agent-c2/src".to_owned(),
26874 "gate4agent-c2/src/lib.rs".to_owned(),
26875 ],
26876 );
26877
26878 std::fs::remove_dir_all(&root).unwrap();
26879 }
26880
26881 #[test]
26882 fn git_parsers_cap_status_and_commit_results() {
26883 let mut status_output = Vec::new();
26884 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26885 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26886 }
26887 let mut snapshot = git_snapshot_for_parser();
26888 parse_git_status(&status_output, &mut snapshot);
26889 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26890 assert!(snapshot.truncated);
26891 assert_eq!(snapshot.status[0].index_status, " ");
26892 assert_eq!(snapshot.status[0].worktree_status, "M");
26893
26894 snapshot.truncated = false;
26895 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26896 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
26897 .collect::<String>();
26898 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
26899 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
26900 assert!(snapshot.truncated);
26901 }
26902
26903 #[test]
26904 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
26905 let mut snapshot = git_snapshot_for_parser();
26906 parse_git_status(
26907 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
26908 &mut snapshot,
26909 );
26910
26911 assert!(!snapshot.truncated);
26912 assert_eq!(snapshot.status.len(), 3);
26913 assert_eq!(snapshot.status[0].index_status, "R");
26914 assert_eq!(snapshot.status[0].worktree_status, " ");
26915 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
26916 assert_eq!(
26917 snapshot.status[0]
26918 .previous_path
26919 .as_ref()
26920 .and_then(RepositoryPath::as_utf8),
26921 Some("src/previous name.rs"),
26922 );
26923 assert_eq!(snapshot.status[1].index_status, " ");
26924 assert_eq!(snapshot.status[1].worktree_status, "C");
26925 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
26926 assert_eq!(
26927 snapshot.status[1]
26928 .previous_path
26929 .as_ref()
26930 .and_then(RepositoryPath::as_utf8),
26931 Some("src/original.rs"),
26932 );
26933 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
26934 assert!(snapshot.status[2].previous_path.is_none());
26935 }
26936
26937 #[test]
26938 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
26939 let mut snapshot = git_snapshot_for_parser();
26940 parse_git_status(
26941 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
26942 &mut snapshot,
26943 );
26944
26945 assert!(snapshot.truncated);
26946 assert_eq!(snapshot.status.len(), 1);
26947 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
26948 assert!(snapshot.status[0].previous_path.is_none());
26949 }
26950
26951 #[tokio::test]
26952 async fn observer_can_browse_host_directories_without_controller() {
26953 let root = temporary_workspace_root("host-directory-observer");
26954 std::fs::create_dir_all(root.join("directory-b")).unwrap();
26955 std::fs::create_dir_all(root.join("directory-a")).unwrap();
26956 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
26957 let workspace_id = WorkspaceId::new("primary").unwrap();
26958 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
26959 let catalog = active_registry().unwrap();
26960 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26961 let shared = NodeShared::new(
26962 handle,
26963 "fixture-token".to_owned(),
26964 NodeId::new("node-1").unwrap(),
26965 vec![workspace],
26966 vec![agent("claude")],
26967 );
26968 let request = NodeRequest::BrowseHostDirectories {
26969 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
26970 after: None,
26971 };
26972 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26973 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
26974 }));
26975 assert!(!request_uses_unnegotiated_capability(
26976 &request,
26977 &baseline_capabilities().unwrap(),
26978 ));
26979 assert!(request_uses_unnegotiated_capability(&request, &[]));
26980
26981 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
26982 .await
26983 .unwrap();
26984 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
26985 panic!("host directory browse returned another response");
26986 };
26987 assert_eq!(
26988 listing
26989 .entries
26990 .iter()
26991 .map(|entry| entry.display_name.as_str())
26992 .collect::<Vec<_>>(),
26993 vec!["directory-a", "directory-b"],
26994 );
26995 assert!(listing.entries.iter().all(|entry| !entry.is_link));
26996 assert!(!listing.incomplete);
26997 std::fs::remove_dir_all(root).unwrap();
26998 }
26999
27000 #[tokio::test]
27001 async fn observer_can_inspect_only_a_registered_workspace() {
27002 let root = temporary_workspace_root("observer");
27003 std::fs::create_dir_all(root.join("src")).unwrap();
27004 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27005 let workspace_id = WorkspaceId::new("primary").unwrap();
27006 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27007 let catalog = active_registry().unwrap();
27008 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27009 let shared = NodeShared::new(
27010 handle,
27011 "fixture-token".to_owned(),
27012 NodeId::new("node-1").unwrap(),
27013 vec![workspace],
27014 vec![agent("claude")],
27015 );
27016
27017 let response = process_request_inner(
27018 &shared,
27019 77,
27020 ClientRole::Observer,
27021 NodeRequest::InspectWorkspace {
27022 workspace_id: workspace_id.clone(),
27023 },
27024 )
27025 .await
27026 .unwrap();
27027 let NodeResponse::WorkspaceInspected { inspection } = response else {
27028 panic!("workspace inspection returned another response");
27029 };
27030 assert_eq!(inspection.workspace_id, workspace_id);
27031 assert!(inspection.entries.iter().any(|entry| {
27032 entry.relative_path.as_utf8() == Some("src/main.rs")
27033 }));
27034
27035 let unknown = process_request_inner(
27036 &shared,
27037 77,
27038 ClientRole::Observer,
27039 NodeRequest::InspectWorkspace {
27040 workspace_id: WorkspaceId::new("unknown").unwrap(),
27041 },
27042 )
27043 .await
27044 .unwrap_err();
27045 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27046 std::fs::remove_dir_all(&root).unwrap();
27047 }
27048
27049 #[test]
27050 fn context_pack_source_accepts_only_running_or_successful_exit() {
27051 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27052 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27053 exit_code: Some(0),
27054 }));
27055 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27056 exit_code: Some(1),
27057 }));
27058 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27059 exit_code: None,
27060 }));
27061 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27062 message: "provider failed".to_owned(),
27063 }));
27064 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27065 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27066 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27067 }
27068
27069 #[test]
27070 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27071 let git = git_snapshot_for_parser();
27072 let first = ContextPackRepositoryObservation {
27073 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27074 git: git.clone(),
27075 files: Vec::new(),
27076 };
27077 let second = ContextPackRepositoryObservation {
27078 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27079 git,
27080 files: Vec::new(),
27081 };
27082
27083 let error = stable_context_pack_repository(first, second).unwrap_err();
27084
27085 assert_eq!(
27086 error.code,
27087 NodeFailureCode::ContextPackMaterializationFailed,
27088 );
27089 }
27090
27091 #[test]
27092 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27093 let git = git_snapshot_for_parser();
27094 let first = ContextPackRepositoryObservation {
27095 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27096 git: git.clone(),
27097 files: vec![ContextPackRepositoryFileSource::utf8(
27098 "README.md",
27099 "first\n".to_owned(),
27100 6,
27101 )],
27102 };
27103 let second = ContextPackRepositoryObservation {
27104 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27105 git,
27106 files: vec![ContextPackRepositoryFileSource::utf8(
27107 "README.md",
27108 "other\n".to_owned(),
27109 6,
27110 )],
27111 };
27112
27113 let error = stable_context_pack_repository(first, second).unwrap_err();
27114
27115 assert_eq!(
27116 error.code,
27117 NodeFailureCode::ContextPackMaterializationFailed,
27118 );
27119 }
27120
27121 #[tokio::test]
27122 async fn native_session_catalog_zero_active_session_fixture() {
27123 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27124 let history_root = temporary_workspace_root("native-catalog-history");
27125 let outside_root = temporary_workspace_root("native-catalog-outside");
27126 std::fs::create_dir_all(&workspace_root).unwrap();
27127 std::fs::create_dir_all(&history_root).unwrap();
27128 std::fs::create_dir_all(&outside_root).unwrap();
27129 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27130 [
27131 serde_json::json!({
27132 "type": "user",
27133 "sessionId": session_id,
27134 "cwd": cwd.to_str().unwrap(),
27135 "message": { "content": "raw user text must not cross the catalog wire" }
27136 }),
27137 serde_json::json!({
27138 "type": "assistant",
27139 "sessionId": session_id,
27140 "cwd": cwd.to_str().unwrap(),
27141 "message": { "model": "claude-sonnet", "content": "raw answer" }
27142 }),
27143 serde_json::json!({
27144 "type": "ai-title",
27145 "sessionId": session_id,
27146 "aiTitle": title
27147 }),
27148 ]
27149 .into_iter()
27150 .map(|record| record.to_string())
27151 .collect::<Vec<_>>()
27152 .join("\n")
27153 };
27154 std::fs::write(
27155 history_root.join("stable-session.jsonl"),
27156 transcript("stable-session", &workspace_root, "Workspace review"),
27157 )
27158 .unwrap();
27159 std::fs::write(
27160 history_root.join("outside-session.jsonl"),
27161 transcript("outside-session", &outside_root, "Outside review"),
27162 )
27163 .unwrap();
27164 let history = NativeHistoryConfig::new(vec![
27165 NativeHistoryRoot::new(
27166 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27167 HistorySourceLayout::SingleNdjson,
27168 history_root.clone(),
27169 )
27170 .unwrap(),
27171 ])
27172 .unwrap();
27173 let catalog = active_registry().unwrap();
27174 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27175 assert!(handle.snapshot().sessions.is_empty());
27176 let workspace_id = WorkspaceId::new("primary").unwrap();
27177 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27178 let mut shared = NodeShared::new(
27179 handle,
27180 "fixture-token".to_owned(),
27181 NodeId::new("node-native-catalog").unwrap(),
27182 vec![workspace],
27183 vec![agent("claude")],
27184 );
27185 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27186 NativeSessionCatalogAuthority::new(history),
27187 )));
27188 let route = NativeSessionCatalogRoute::workspace(
27189 workspace_id.clone(),
27190 agent("claude"),
27191 );
27192 let request = NodeRequest::CatalogNativeSessions {
27193 route: route.clone(),
27194 limit: 2,
27195 };
27196 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27197 .await
27198 .unwrap_err();
27199 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27200 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27201 .await
27202 .unwrap();
27203 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27204 panic!("native catalog returned a different response");
27205 };
27206 let summary = summary.expect("node must emit native session catalog summary metadata");
27207 assert_eq!(entries.len(), 1);
27208 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27209 assert_eq!(entries[0].external_group, None);
27210 assert_eq!(entries[0].record_id, None);
27211 assert_eq!(entries[0].title, None);
27212 assert_eq!(entries[0].model, None);
27213 assert_eq!(entries[0].message_count, 0);
27214 let encoded_entries = serde_json::to_string(&entries).unwrap();
27215 assert!(!encoded_entries.contains("stable-session"));
27216 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27217 let page_request = NodeRequest::PageNativeSessions {
27218 route: route.clone(),
27219 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27220 catalog_revision: summary.catalog_revision,
27221 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27222 after_selection_id: None,
27223 limit: 2,
27224 };
27225 let observer = process_request_inner(
27226 &shared,
27227 7,
27228 ClientRole::Observer,
27229 page_request.clone(),
27230 )
27231 .await
27232 .unwrap_err();
27233 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27234 let page_response = process_request_inner(
27235 &shared,
27236 7,
27237 ClientRole::Operator,
27238 page_request.clone(),
27239 )
27240 .await
27241 .unwrap();
27242 assert!(matches!(
27243 page_response,
27244 NodeResponse::NativeSessionsPaged { page, .. }
27245 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27246 ));
27247 let stale = process_request_inner(
27248 &shared,
27249 7,
27250 ClientRole::Operator,
27251 NodeRequest::PageNativeSessions {
27252 route: route.clone(),
27253 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27254 catalog_revision: summary.catalog_revision.wrapping_add(1),
27255 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27256 after_selection_id: None,
27257 limit: 2,
27258 },
27259 )
27260 .await
27261 .unwrap_err();
27262 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27263 let selection = NativeSessionSelection {
27264 route: route.clone(),
27265 catalog_revision: summary.catalog_revision,
27266 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27267 selection_id: entries[0].selection_id.clone(),
27268 };
27269 let preview_response = process_request_inner(
27270 &shared,
27271 7,
27272 ClientRole::Operator,
27273 NodeRequest::PreviewNativeSession {
27274 selection: selection.clone(),
27275 message_limit: 1,
27276 },
27277 )
27278 .await
27279 .unwrap();
27280 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27281 panic!("native preview returned a different response");
27282 };
27283 assert_eq!(preview.messages.len(), 1);
27284 assert!(preview.message_count_exact);
27285 assert_eq!(preview.messages[0].text, "raw answer");
27286 assert!(preview.truncated);
27287
27288 let index_request = NodeRequest::IndexNativeSession {
27289 selection: selection.clone(),
27290 display_name: "Saved Claude".to_owned(),
27291 };
27292 let observer = process_request_inner(
27293 &shared,
27294 7,
27295 ClientRole::Observer,
27296 index_request.clone(),
27297 )
27298 .await
27299 .unwrap_err();
27300 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27301 let without_controller = process_request_inner(
27302 &shared,
27303 7,
27304 ClientRole::Operator,
27305 index_request.clone(),
27306 )
27307 .await
27308 .unwrap_err();
27309 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27310 let acquired = process_request_inner(
27311 &shared,
27312 7,
27313 ClientRole::Operator,
27314 NodeRequest::AcquireController { lease_ms: 5_000 },
27315 )
27316 .await
27317 .unwrap();
27318 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27319 let indexed = process_request_inner(
27320 &shared,
27321 7,
27322 ClientRole::Operator,
27323 index_request,
27324 )
27325 .await
27326 .unwrap();
27327 assert!(indexed.native_session_index_contract_is_valid());
27328 let NodeResponse::NativeSessionIndexed {
27329 selection: echoed_selection,
27330 record,
27331 } = indexed else {
27332 panic!("native session index returned a different response");
27333 };
27334 assert_eq!(echoed_selection, selection);
27335 let recataloged = process_request_inner(
27336 &shared,
27337 7,
27338 ClientRole::Operator,
27339 NodeRequest::CatalogNativeSessions {
27340 route,
27341 limit: 2,
27342 },
27343 )
27344 .await
27345 .unwrap();
27346 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27347 panic!("native catalog returned a different response after indexing");
27348 };
27349 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27350
27351 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27352 let external_catalog = process_request_inner(
27353 &shared,
27354 7,
27355 ClientRole::Operator,
27356 NodeRequest::CatalogNativeSessions {
27357 route: unregistered_route.clone(),
27358 limit: 2,
27359 },
27360 )
27361 .await
27362 .unwrap();
27363 let external_json = serde_json::to_string(&external_catalog).unwrap();
27364 assert!(!external_json.contains("outside-session"));
27365 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27366 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27367 assert!(!external_json.contains("\"session_id\""));
27368 assert!(!external_json.contains("\"cwd\""));
27369 assert!(!external_json.contains("\"path\""));
27370 let NodeResponse::NativeSessionsCataloged {
27371 route: echoed_route,
27372 entries: external_entries,
27373 summary: Some(external_summary),
27374 } = external_catalog
27375 else {
27376 panic!("unregistered native catalog returned a different response");
27377 };
27378 assert_eq!(echoed_route, unregistered_route);
27379 assert_eq!(external_entries.len(), 1);
27380 assert_eq!(external_entries[0].record_id, None);
27381 let external_group = external_entries[0]
27382 .external_group
27383 .as_ref()
27384 .expect("unregistered session must have an external group");
27385 assert_eq!(
27386 external_group.kind,
27387 gate4agent_types::NativeSessionExternalGroupKind::Project,
27388 );
27389 assert!(external_group.group_id.starts_with("external-"));
27390 assert_eq!(
27391 external_group.display_name,
27392 outside_root.file_name().unwrap().to_string_lossy(),
27393 );
27394 let external_selection = NativeSessionSelection {
27395 route: unregistered_route,
27396 catalog_revision: external_summary.catalog_revision,
27397 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27398 selection_id: external_entries[0].selection_id.clone(),
27399 };
27400 let external_preview = process_request_inner(
27401 &shared,
27402 7,
27403 ClientRole::Operator,
27404 NodeRequest::PreviewNativeSession {
27405 selection: external_selection.clone(),
27406 message_limit: 1,
27407 },
27408 )
27409 .await
27410 .unwrap();
27411 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27412 assert!(!external_preview_json.contains("outside-session"));
27413 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27414 assert!(!external_preview_json.contains("\"session_id\""));
27415 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27416 panic!("unregistered native preview returned a different response");
27417 };
27418 assert_eq!(preview.messages.len(), 1);
27419 let external_index = process_request_inner(
27420 &shared,
27421 7,
27422 ClientRole::Operator,
27423 NodeRequest::IndexNativeSession {
27424 selection: external_selection,
27425 display_name: "External Claude".to_owned(),
27426 },
27427 )
27428 .await
27429 .unwrap_err();
27430 assert_eq!(
27431 external_index.code,
27432 NodeFailureCode::WorkspaceRegistrationRequired,
27433 );
27434 let record_response = process_request_inner(
27435 &shared,
27436 7,
27437 ClientRole::Operator,
27438 NodeRequest::PreviewSessionRecord {
27439 record_id: record.record_id.clone(),
27440 message_limit: 2,
27441 },
27442 )
27443 .await
27444 .unwrap();
27445 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27446 panic!("record preview returned a different response");
27447 };
27448 assert_eq!(record_id, record.record_id);
27449 assert_eq!(preview.messages.len(), 2);
27450 let encoded = serde_json::to_string(&preview).unwrap();
27451 assert!(!encoded.contains("stable-session"));
27452 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27453 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27454 panic!("record preview history summary returned a different response");
27455 };
27456 let summaries = events
27457 .iter()
27458 .filter_map(|envelope| match &envelope.event {
27459 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27460 if record_id == &record.record_id =>
27461 {
27462 Some(*summary)
27463 }
27464 _ => None,
27465 })
27466 .collect::<Vec<_>>();
27467 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27468 let summary_json = serde_json::to_string(&summaries).unwrap();
27469 for private in [
27470 "stable-session",
27471 "raw user text must not cross the catalog wire",
27472 "raw answer",
27473 "Workspace review",
27474 "claude-sonnet",
27475 ] {
27476 assert!(!summary_json.contains(private), "{private}");
27477 }
27478 let identity = record.provider_session.clone().unwrap();
27479 assert_eq!(
27480 shared.revalidate_session_record_preview(&record, &identity),
27481 Ok(())
27482 );
27483 shared.remove_record_memory(&record.record_id);
27484 assert_eq!(
27485 shared
27486 .revalidate_session_record_preview(&record, &identity)
27487 .unwrap_err()
27488 .code,
27489 NodeFailureCode::UnknownSessionRecord
27490 );
27491 let released = process_request_inner(
27492 &shared,
27493 7,
27494 ClientRole::Operator,
27495 NodeRequest::ReleaseController,
27496 )
27497 .await
27498 .unwrap();
27499 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27500 assert!(shared.controller_state().is_none());
27501 std::fs::remove_dir_all(workspace_root).unwrap();
27502 std::fs::remove_dir_all(history_root).unwrap();
27503 std::fs::remove_dir_all(outside_root).unwrap();
27504 }
27505
27506 #[tokio::test]
27507 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27508 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27509 std::fs::create_dir_all(&history_root).unwrap();
27510 let history = NativeHistoryConfig::new(vec![
27511 NativeHistoryRoot::new(
27512 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27513 HistorySourceLayout::SingleNdjson,
27514 history_root.clone(),
27515 )
27516 .unwrap(),
27517 ])
27518 .unwrap();
27519 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27520 let held = catalog.lock().unwrap();
27521 let result = timeout(
27522 Duration::from_secs(1),
27523 run_native_session_catalog_operation(
27524 Arc::clone(&catalog),
27525 "native session catalog",
27526 |_| Ok::<(), std::convert::Infallible>(()),
27527 ),
27528 )
27529 .await
27530 .expect("concurrent native catalog request must not wait for the operation deadline")
27531 .unwrap_err();
27532 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27533 drop(held);
27534 std::fs::remove_dir_all(history_root).unwrap();
27535 }
27536
27537 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27538 let unique = std::time::SystemTime::now()
27539 .duration_since(std::time::UNIX_EPOCH)
27540 .unwrap()
27541 .as_nanos();
27542 std::env::temp_dir().join(format!(
27543 "gate4agent-node-{label}-{}-{unique}",
27544 std::process::id(),
27545 ))
27546 }
27547
27548 fn git_snapshot_for_parser() -> GitSnapshot {
27549 GitSnapshot {
27550 is_repository: true,
27551 branch: Some("main".to_owned()),
27552 status: Vec::new(),
27553 recent_commits: Vec::new(),
27554 worktrees: Vec::new(),
27555 managed_worktree: None,
27556 truncated: false,
27557 diagnostic: None,
27558 }
27559 }
27560
27561 #[tokio::test]
27562 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27563 let root = temporary_workspace_root("managed-git-scope");
27564 std::fs::create_dir_all(&root).unwrap();
27565 run_test_git(&root, &["init"]);
27566 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27567 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27568 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27569 run_test_git(&root, &["add", "seed.txt"]);
27570 run_test_git(&root, &["commit", "-m", "seed"]);
27571 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27572
27573 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27574 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27575 let catalog = active_registry().unwrap();
27576 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27577 let shared = NodeShared::new(
27578 handle,
27579 "fixture-token".to_owned(),
27580 NodeId::new("node-1").unwrap(),
27581 vec![workspace],
27582 vec![agent("claude")],
27583 );
27584 let mut lease = ManagedWorktreeLeaseRecord {
27585 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27586 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27587 workspace_id: workspace_id.clone(),
27588 profile_id: WorktreeProfileId::new("default").unwrap(),
27589 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27590 target_root: root.to_string_lossy().into_owned(),
27591 branch: "gate4agent/a".to_owned(),
27592 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27593 expected_head: None,
27594 retention: ManagedWorktreeRetention::Retain,
27595 state: ManagedWorktreeLeaseState::InUse,
27596 session_holders: vec![ManagedWorktreeSessionHolder {
27597 incarnation_id: shared.incarnation_id,
27598 instance_id: AgentInstanceId(7),
27599 generation: SessionGeneration(1),
27600 }],
27601 record_holders: Vec::new(),
27602 cleanup_failure: None,
27603 created_at_unix_ms: 1,
27604 updated_at_unix_ms: 1,
27605 };
27606 *shared
27607 .managed_worktrees
27608 .lock()
27609 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27610 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27611
27612 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27613 let scope = inspection.git.managed_worktree.unwrap();
27614 assert_eq!(scope.lease_id, lease.lease_id);
27615 assert_eq!(scope.branch, "gate4agent/a");
27616 assert_eq!(scope.active_session_count, 1);
27617
27618 lease.branch = "gate4agent/b".to_owned();
27619 *shared
27620 .managed_worktrees
27621 .lock()
27622 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27623 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27624 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27625 assert!(mismatch.git.managed_worktree.is_none());
27626 std::fs::remove_dir_all(root).unwrap();
27627 }
27628
27629 #[tokio::test]
27630 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27631 let root = temporary_workspace_root("workspace-file-write-controller");
27632 std::fs::create_dir_all(root.join("src")).unwrap();
27633 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27634 let workspace_id = WorkspaceId::new("primary").unwrap();
27635 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27636 let catalog = active_registry().unwrap();
27637 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27638 let shared = NodeShared::new(
27639 handle,
27640 "fixture-token".to_owned(),
27641 NodeId::new("node-1").unwrap(),
27642 vec![workspace],
27643 vec![agent("claude")],
27644 );
27645 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27646 let expected_revision = workspace_file_revision(b"old\n");
27647 let request = NodeRequest::WriteWorkspaceFile {
27648 workspace_id: workspace_id.clone(),
27649 path: path.clone(),
27650 expected_revision: expected_revision.clone(),
27651 text: "new\n".to_owned(),
27652 };
27653 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27654 .await
27655 .unwrap_err();
27656 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27657 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27658 .await
27659 .unwrap_err();
27660 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27661 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27662 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27663 let NodeResponse::WorkspaceFileWritten { file } = response else {
27664 panic!("workspace file write returned another response");
27665 };
27666 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27667 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27668 let stale = process_request_inner(
27669 &shared,
27670 77,
27671 ClientRole::Operator,
27672 NodeRequest::WriteWorkspaceFile {
27673 workspace_id,
27674 path,
27675 expected_revision,
27676 text: "lost update\n".to_owned(),
27677 },
27678 )
27679 .await
27680 .unwrap_err();
27681 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27682 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27683 std::fs::remove_dir_all(root).unwrap();
27684 }
27685
27686 #[tokio::test]
27687 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27688 let root = temporary_workspace_root("workspace-entry-create-controller");
27689 std::fs::create_dir_all(root.join("src")).unwrap();
27690 let workspace_id = WorkspaceId::new("primary").unwrap();
27691 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27692 let catalog = active_registry().unwrap();
27693 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27694 let shared = NodeShared::new(
27695 handle,
27696 "fixture-token".to_owned(),
27697 NodeId::new("node-1").unwrap(),
27698 vec![workspace],
27699 vec![agent("claude")],
27700 );
27701 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27702 let request = NodeRequest::CreateWorkspaceFile {
27703 workspace_id: workspace_id.clone(),
27704 path: path.clone(),
27705 };
27706 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27707 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27708 }));
27709 assert!(!request_uses_unnegotiated_capability(
27710 &request,
27711 &baseline_capabilities().unwrap(),
27712 ));
27713 assert!(request_uses_unnegotiated_capability(&request, &[]));
27714 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27715 .await
27716 .unwrap_err();
27717 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27718 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27719 .await
27720 .unwrap_err();
27721 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27722 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27723
27724 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27725 .await
27726 .unwrap();
27727 let NodeResponse::WorkspaceFileCreated { file } = response else {
27728 panic!("workspace file create returned another response");
27729 };
27730 assert_eq!(file.workspace_id, workspace_id);
27731 assert_eq!(file.path, path);
27732 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27733 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27734
27735 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27736 .await
27737 .unwrap_err();
27738 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27739
27740 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27741 let response = process_request_inner(
27742 &shared,
27743 77,
27744 ClientRole::Operator,
27745 NodeRequest::CreateWorkspaceDirectory {
27746 workspace_id: workspace_id.clone(),
27747 path: directory_path.clone(),
27748 },
27749 )
27750 .await
27751 .unwrap();
27752 assert_eq!(
27753 response,
27754 NodeResponse::WorkspaceDirectoryCreated {
27755 workspace_id: workspace_id.clone(),
27756 entry: WorkspaceEntry {
27757 relative_path: directory_path,
27758 kind: WorkspaceEntryKind::Directory,
27759 },
27760 },
27761 );
27762 assert!(root.join("src/nested").is_dir());
27763
27764 let missing_parent = process_request_inner(
27765 &shared,
27766 77,
27767 ClientRole::Operator,
27768 NodeRequest::CreateWorkspaceFile {
27769 workspace_id: workspace_id.clone(),
27770 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27771 },
27772 )
27773 .await
27774 .unwrap_err();
27775 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27776 let non_directory_parent = process_request_inner(
27777 &shared,
27778 77,
27779 ClientRole::Operator,
27780 NodeRequest::CreateWorkspaceFile {
27781 workspace_id,
27782 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27783 },
27784 )
27785 .await
27786 .unwrap_err();
27787 assert_eq!(
27788 non_directory_parent.code,
27789 NodeFailureCode::RepositoryParentNotDirectory,
27790 );
27791 std::fs::remove_dir_all(root).unwrap();
27792 }
27793
27794 #[tokio::test]
27795 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27796 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27797 std::fs::create_dir_all(root.join("src")).unwrap();
27798 #[cfg(windows)]
27799 let root = std::fs::canonicalize(root).unwrap();
27800 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27801 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27802 let barrier = Arc::new(std::sync::Barrier::new(2));
27803 let done = Arc::new(AtomicBool::new(false));
27804 let task_commit_state = Arc::clone(&commit_state);
27805 let task_barrier = Arc::clone(&barrier);
27806 let task_done = Arc::clone(&done);
27807 let task_root = root.clone();
27808 let task = tokio::task::spawn_blocking(move || {
27809 task_barrier.wait();
27810 let result = create_workspace_file_on_disk(
27811 &task_root,
27812 &path,
27813 &task_commit_state,
27814 );
27815 task_done.store(true, Ordering::Release);
27816 result
27817 });
27818
27819 let error = settle_workspace_entry_create(
27820 task,
27821 Arc::clone(&commit_state),
27822 Duration::from_millis(10),
27823 "fixture deadline",
27824 "fixture task failed",
27825 )
27826 .await
27827 .unwrap_err();
27828 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27829 assert_eq!(
27830 commit_state.load(Ordering::Acquire),
27831 WORKSPACE_ENTRY_CREATE_CANCELED,
27832 );
27833
27834 barrier.wait();
27835 timeout(Duration::from_secs(1), async {
27836 while !done.load(Ordering::Acquire) {
27837 tokio::task::yield_now().await;
27838 }
27839 })
27840 .await
27841 .expect("canceled blocking create did not settle");
27842 assert!(!root.join("src/late.rs").exists());
27843 std::fs::remove_dir_all(root).unwrap();
27844 }
27845}
27846
27847#[cfg(test)]
27848mod provisional_spawn_tests {
27849 use super::*;
27850 use std::sync::Arc;
27851
27852 fn shared() -> NodeShared {
27853 let catalog = active_registry().unwrap();
27854 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27855 let workspace = WorkspaceConfig::new(
27856 WorkspaceId::new("primary").unwrap(),
27857 std::env::current_dir().unwrap(),
27858 )
27859 .unwrap()
27860 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27861 NodeShared::new(
27862 handle,
27863 "fixture-token".to_owned(),
27864 NodeId::new("node-provisional-test").unwrap(),
27865 vec![workspace],
27866 vec![AgentId::new("claude").unwrap()],
27867 )
27868 }
27869
27870 #[test]
27873 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27874 let shared = shared();
27875 let ws = WorkspaceId::new("primary").unwrap();
27876 let mut ids = Vec::with_capacity(50);
27877 for i in 1u64..=50 {
27878 let id = AgentInstanceId(i);
27879 ids.push(id);
27880 let address = SessionAddress {
27881 workspace_id: ws.clone(),
27882 session: SessionKey {
27883 instance_id: id,
27884 generation: SessionGeneration(1),
27885 },
27886 };
27887 shared.mark_spawn_provisional(address);
27888 }
27889 assert_eq!(
27890 shared
27891 .provisional_spawns
27892 .lock()
27893 .unwrap_or_else(|p| p.into_inner())
27894 .len(),
27895 50
27896 );
27897 for id in ids.iter().step_by(2) {
27898 shared.sessionize_spawn(*id);
27899 }
27900 assert_eq!(
27901 shared
27902 .provisional_spawns
27903 .lock()
27904 .unwrap_or_else(|p| p.into_inner())
27905 .len(),
27906 25
27907 );
27908 assert!(shared.take_due_provisional_spawns().is_empty());
27909 {
27910 let mut map = shared
27911 .provisional_spawns
27912 .lock()
27913 .unwrap_or_else(|p| p.into_inner());
27914 let past = Instant::now() - Duration::from_secs(30);
27915 for (_, (_, at)) in map.iter_mut() {
27916 *at = past;
27917 }
27918 }
27919 let due = shared.take_due_provisional_spawns();
27920 assert_eq!(due.len(), 25);
27921 let due_ids: std::collections::BTreeSet<_> =
27922 due.iter().map(|a| a.session.instance_id.0).collect();
27923 let expected: std::collections::BTreeSet<_> =
27924 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
27925 assert_eq!(due_ids, expected);
27926 assert!(shared
27927 .provisional_spawns
27928 .lock()
27929 .unwrap_or_else(|p| p.into_inner())
27930 .is_empty());
27931 }
27932
27933 #[test]
27936 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
27937 let shared = Arc::new(shared());
27938 let ws = WorkspaceId::new("primary").unwrap();
27939 let mut handles = Vec::new();
27940 for i in 1u64..=20 {
27941 let shared = Arc::clone(&shared);
27942 let ws = ws.clone();
27943 handles.push(std::thread::spawn(move || {
27944 let id = AgentInstanceId(i);
27945 let address = SessionAddress {
27946 workspace_id: ws,
27947 session: SessionKey {
27948 instance_id: id,
27949 generation: SessionGeneration(1),
27950 },
27951 };
27952 shared.mark_spawn_provisional(address);
27953 id
27954 }));
27955 }
27956 let mut ids = Vec::new();
27957 for h in handles {
27958 ids.push(h.join().unwrap());
27959 }
27960 ids.sort_by_key(|id| id.0);
27961 assert_eq!(ids.len(), 20);
27962 ids.dedup();
27963 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
27964 assert_eq!(
27965 shared
27966 .provisional_spawns
27967 .lock()
27968 .unwrap_or_else(|p| p.into_inner())
27969 .len(),
27970 20
27971 );
27972 shared.sessionize_spawn(AgentInstanceId(7));
27973 let left: Vec<_> = shared
27974 .provisional_spawns
27975 .lock()
27976 .unwrap_or_else(|p| p.into_inner())
27977 .keys()
27978 .map(|id| id.0)
27979 .collect();
27980 assert!(!left.contains(&7));
27981 assert_eq!(left.len(), 19);
27982 }
27983}
27984
27985
27986#[cfg(unix)]
27987extern "C" {
27988 #[link_name = "geteuid"]
27989 fn libc_geteuid() -> u32;
27990}
27991
27992#[cfg(test)]
27993mod call_home_link_tests {
27994 use super::*;
27995
27996 #[test]
27997 fn call_home_backoff_is_capped_and_monotonic() {
27998 let mut last = Duration::ZERO;
27999 for attempt in 1..20 {
28000 let next = call_home_backoff(attempt);
28001 assert!(next >= last);
28002 assert!(next <= Duration::from_secs(8));
28003 last = next;
28004 }
28005 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28006 }
28007
28008 #[tokio::test]
28009 async fn call_home_socket_gets_keepalive() {
28010 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28011 let addr = listener.local_addr().unwrap();
28012 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28013 let client = client.unwrap();
28014 enable_call_home_keepalive(&client).unwrap();
28015 let sock = socket2::SockRef::from(&client);
28016 assert!(sock.keepalive().unwrap());
28017 #[cfg(any(target_os = "linux", target_os = "macos"))]
28018 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28019 }
28020}