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 grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3221 #[cfg(unix)]
3224 signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3225 materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3226 spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3227 managed_spawn_replays:
3228 Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3229 controller: Mutex<Option<ControllerLease>>,
3230 provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3233 pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3235 history: Mutex<NodeEventHistory>,
3236 event_tx: broadcast::Sender<NodeEventEnvelope>,
3237 terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3238 agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3246 harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3255 terminal_frame_watermarks:
3256 Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3257 pty_input_wake: tokio::sync::Notify,
3259 pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3262 next_connection_id: AtomicU64,
3263 next_instance_id: AtomicU64,
3264 next_command_id: AtomicU64,
3265 shutdown: AtomicBool,
3266 shutdown_notify: Notify,
3267 preauth_slots: Arc<Semaphore>,
3268 authenticated_slots: Arc<Semaphore>,
3269 inspection_slots: Arc<Semaphore>,
3270 mutation_gate: AsyncMutex<()>,
3271 session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3272 session_records: Mutex<SessionRecords>,
3273 state_path: Option<PathBuf>,
3274 persistence_error: RwLock<Option<String>>,
3275 state_transaction: Mutex<()>,
3276 input_settle_timeout_ms: u64,
3277 drive_loop_profile: Mutex<DriveLoopProfiler>,
3281 runtime_tick_profile: Mutex<TickProfileSnapshot>,
3287 shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3299 native_session_gauge: AtomicUsize,
3306 connection_loop_iterations: AtomicU64,
3317 connection_events_sent: AtomicU64,
3318 rejected_commands_total: AtomicU64,
3327 rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3347 drive_loop_iterations_total: AtomicU64,
3348 drive_loop_iterations_idle: AtomicU64,
3349 session_record_retention: SessionRecordRetentionConfig,
3353 retired_records_total: AtomicU64,
3359 #[cfg(feature = "fixture")]
3360 fixture_semantic_hook_policy: bool,
3361 #[cfg(feature = "fixture")]
3362 fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3363}
3364
3365impl Drop for NodeShared {
3366 fn drop(&mut self) {
3367 SessionLifecycleHook::on_shutdown(self);
3368 }
3369}
3370
3371#[derive(Clone, Debug, Eq, PartialEq)]
3372struct SessionBinding {
3373 workspace_id: WorkspaceId,
3374 generation: SessionGeneration,
3375 runtime_policy: ProviderRuntimePolicy,
3376 pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3377 record_id: Option<SessionRecordId>,
3378 managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3379 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3380 bundle: Option<ResolvedBundleReceipt>,
3381 context: Option<ResolvedContextPackReceipt>,
3382 materialization_id: Option<MaterializationId>,
3383}
3384
3385#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3386enum SpawnRecordPolicy {
3387 ProviderIdentityOnly,
3388 Always,
3389}
3390
3391struct NativeEnvironmentSelectionGuard {
3392 control: Option<NativeLaunchProfileControl>,
3393 instance_id: AgentInstanceId,
3394}
3395
3396struct NativeInstanceOverlayGuard {
3397 control: Option<NativeLaunchProfileControl>,
3398 instance_id: AgentInstanceId,
3399}
3400
3401struct NativeHarnessMcpOverlayGuard {
3402 control: NativeLaunchProfileControl,
3403 instance_id: AgentInstanceId,
3404}
3405
3406impl NativeHarnessMcpOverlayGuard {
3407 fn retain(self) { std::mem::forget(self); }
3408}
3409
3410impl Drop for NativeHarnessMcpOverlayGuard {
3411 fn drop(&mut self) {
3412 self.control
3413 .clear_native_mcp_server_launch_overlay(self.instance_id);
3414 }
3415}
3416
3417impl NativeInstanceOverlayGuard {
3418 fn retain(mut self) {
3419 self.control = None;
3420 }
3421}
3422
3423impl Drop for NativeInstanceOverlayGuard {
3424 fn drop(&mut self) {
3425 if let Some(control) = self.control.as_ref() {
3426 control.clear_native_instance_launch_overlay(self.instance_id);
3427 }
3428 }
3429}
3430
3431enum PreparedNativeLaunchOverlay {
3432 Environment(NativeLaunchEnvironmentOverlay),
3433 Instance(NativeInstanceLaunchOverlay),
3434}
3435
3436impl NativeEnvironmentSelectionGuard {
3437 fn retain(mut self) {
3438 self.control = None;
3439 }
3440}
3441
3442impl Drop for NativeEnvironmentSelectionGuard {
3443 fn drop(&mut self) {
3444 if let Some(control) = self.control.as_ref() {
3445 control.clear_native_launch_profile_selection(self.instance_id);
3446 }
3447 }
3448}
3449
3450struct SessionMaterializationGuard<'a> {
3451 shared: &'a NodeShared,
3452 id: Option<MaterializationId>,
3453 provider_home: Option<PathBuf>,
3456}
3457
3458impl SessionMaterializationGuard<'_> {
3459 fn id(&self) -> Option<&MaterializationId> {
3460 self.id.as_ref()
3461 }
3462
3463 fn provider_home(&self) -> Option<&Path> {
3464 self.provider_home.as_deref()
3465 }
3466
3467 fn retain(mut self) {
3468 self.id = None;
3469 }
3470}
3471
3472impl Drop for SessionMaterializationGuard<'_> {
3473 fn drop(&mut self) {
3474 if let Some(id) = self.id.take() {
3475 let _ = self.shared.cleanup_materialization(&id);
3476 }
3477 }
3478}
3479
3480
3481trait SessionLifecycleHook {
3484 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3486 fn on_session_ended(&self, address: &SessionAddress);
3488 fn on_shutdown(&self);
3490 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3492 fn cleanup_session_materialization(
3494 &self,
3495 address: &SessionAddress,
3496 binding: &SessionBinding,
3497 ) -> Result<(), NodeFailure>;
3498}
3499
3500impl SessionLifecycleHook for NodeShared {
3501 fn on_session_ended(&self, address: &SessionAddress) {
3502 self.retire_session_grok_leader(address.session.instance_id);
3503 if let Some(accounts) = self.m4a_accounts.as_ref() {
3504 let instance = address.session.instance_id;
3505 let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3506 for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3507 accounts.remove_session_file(nick);
3508 }
3509 }
3510 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3511 registry.revoke_session(address);
3512 }
3513 if let Some(control) = self.native_launch_profile_control.as_ref() {
3514 control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3515 }
3516 }
3517
3518 fn on_shutdown(&self) {
3519 let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3520 .into_values()
3521 .collect();
3522 for socket in leaders {
3523 crate::grok_leader::retire(&socket);
3524 }
3525 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3526 let instances = registry.shutdown();
3527 if let Some(control) = self.native_launch_profile_control.as_ref() {
3528 for instance_id in instances {
3529 control.clear_native_mcp_server_launch_overlay(instance_id);
3530 }
3531 }
3532 }
3533 }
3534
3535 fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3536 if let Some(control) = self.native_launch_profile_control.as_ref() {
3537 control.clear_native_mcp_server_launch_overlay(instance_id);
3538 }
3539 }
3540
3541 fn cleanup_session_materialization(
3542 &self,
3543 address: &SessionAddress,
3544 binding: &SessionBinding,
3545 ) -> Result<(), NodeFailure> {
3546 self.cleanup_session_owned_materialization(address, binding)
3547 }
3548
3549 fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3550 #[cfg(feature = "dig2-station-probe")]
3553 {
3554 crate::dig2_station_lease::release_lease_for_holder(
3555 &self.browser_station_leases,
3556 address.session.instance_id,
3557 );
3558 }
3559 if let Some(control) = self.native_launch_profile_control.as_ref() {
3560 control.clear_native_mcp_server_launch_overlay(
3561 address.session.instance_id,
3562 );
3563 }
3564 if let Some(registry) = self.harness_mcp_registry.as_ref() {
3565 registry.revoke_session(address);
3566 }
3567 if removed
3568 .is_some_and(|binding| {
3569 binding.environment_profile.is_some()
3570 || binding.bundle.is_some()
3571 || binding.context.is_some()
3572 })
3573 {
3574 if let Some(control) = self.native_launch_profile_control.as_ref() {
3575 control.clear_native_launch_profile_selection(
3576 address.session.instance_id,
3577 );
3578 control.clear_native_instance_launch_overlay(
3579 address.session.instance_id,
3580 );
3581 }
3582 }
3583 }
3584}
3585
3586struct SpawnPrepareRequest<'r> {
3587 instance_id: AgentInstanceId,
3588 address: &'r SessionAddress,
3589 provider: &'r AgentId,
3590 mode: SessionMode,
3591 environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3592 bundle: Option<&'r ResolvedBundleReceipt>,
3593 context: Option<&'r ResolvedContextPackReceipt>,
3594 managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3595 harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3596 approval_level: ApprovalLevel,
3597 network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3598 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3599}
3600
3601struct PreparedSpawn<'a> {
3605 instance_overlay: Option<NativeInstanceOverlayGuard>,
3606 environment_selection: Option<NativeEnvironmentSelectionGuard>,
3607 materialization_guard: Option<SessionMaterializationGuard<'a>>,
3608 harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3609 #[cfg(feature = "dig2-station-probe")]
3610 browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3611 materialization_id: Option<MaterializationId>,
3612}
3613
3614impl PreparedSpawn<'_> {
3615 fn retain_session_guards(&mut self) {
3617 if let Some(selection) = self.environment_selection.take() {
3618 selection.retain();
3619 }
3620 if let Some(overlay) = self.instance_overlay.take() {
3621 overlay.retain();
3622 }
3623 if let Some(guard) = self.materialization_guard.take() {
3624 guard.retain();
3625 }
3626 }
3627
3628 fn retain_launch_guards(&mut self) {
3630 if let Some(overlay) = self.harness_mcp_overlay.take() {
3631 overlay.retain();
3632 }
3633 #[cfg(feature = "dig2-station-probe")]
3634 if let Some(guard) = self.browser_station_lease_guard.take() {
3635 guard.retain();
3636 }
3637 }
3638}
3639
3640trait SpawnPreparer {
3644 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3645 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 fn install_launch_overlay(
3655 &self,
3656 instance_id: AgentInstanceId,
3657 overlay: PreparedNativeLaunchOverlay,
3658 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3659}
3660
3661impl SpawnPreparer for NodeShared {
3662 fn select_environment(
3663 &self,
3664 instance_id: AgentInstanceId,
3665 provider: &AgentId,
3666 mode: SessionMode,
3667 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3668 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3669 self.select_environment_profile(instance_id, provider, mode, environment_profile)
3670 }
3671
3672 fn install_launch_overlay(
3673 &self,
3674 instance_id: AgentInstanceId,
3675 overlay: PreparedNativeLaunchOverlay,
3676 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3677 self.install_prepared_launch_overlay(instance_id, overlay)
3678 }
3679
3680 fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3681 let SpawnPrepareRequest {
3682 instance_id,
3683 address,
3684 provider,
3685 mode,
3686 environment_profile,
3687 bundle,
3688 context,
3689 managed_authority,
3690 harness_mcp,
3691 approval_level,
3692 network_allowlist,
3693 browser_profile_id,
3694 } = request;
3695 #[cfg(feature = "dig2-station-probe")]
3700 let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3701 use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3702 match acquire_lease(
3703 &self.browser_station_leases,
3704 profile_id,
3705 instance_id,
3706 unix_time_ms(),
3707 ) {
3708 Ok(guard) => Some(guard),
3709 Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3710 return Err(failure(
3711 NodeFailureCode::BrowserStationProfileBusy,
3712 "dig2browser station profile is already leased by another session",
3713 ));
3714 }
3715 Err(BrowserStationLeaseError::CapacityExceeded) => {
3716 return Err(failure(
3717 NodeFailureCode::BrowserStationProfileBusy,
3718 "dig2browser station profile lease capacity exhausted",
3719 ));
3720 }
3721 Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3722 return Err(failure(
3723 NodeFailureCode::BrowserStationProfileBusy,
3724 "session already holds a dig2browser station profile lease",
3725 ));
3726 }
3727 }
3728 } else {
3729 None
3730 };
3731 #[cfg(not(feature = "dig2-station-probe"))]
3732 let _ = browser_profile_id;
3733 let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3734 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3743 {
3744 return Err(failure(
3745 NodeFailureCode::BindingMismatch,
3746 "harness MCP reservation does not match the exact PTY/ACP provider",
3747 ));
3748 }
3749 prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3750 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3751 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3752 })?;
3753 let launch = prepared.launch();
3758 let mut env_entries = vec![
3759 (
3760 OsString::from(launch.endpoint_env.as_str()),
3761 prepared.endpoint().as_os_str().to_os_string(),
3762 ),
3763 (
3764 OsString::from(launch.token_env.as_str()),
3765 prepared.token().expose().into(),
3766 ),
3767 (
3768 OsString::from(launch.program_env.as_str()),
3769 prepared.helper_program().as_os_str().to_os_string(),
3770 ),
3771 ];
3772 if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3773 env_entries.push(trace_entry);
3774 }
3775 if let Some(door) = crate::door::door_listen_addr() {
3776 env_entries.push((
3777 OsString::from("HATCHERY_NODE_DOOR"),
3778 OsString::from(door.to_string()),
3779 ));
3780 let session_key = format!(
3784 "pty-{}-{}-{}",
3785 self.incarnation_id,
3786 address.workspace_id.as_str(),
3787 address.session.instance_id.0
3788 );
3789 env_entries.push((
3790 OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3791 OsString::from(session_key),
3792 ));
3793 }
3794 let overlay = NativeMcpServerLaunchOverlay::new(
3795 provider.clone(),
3796 launch.server_name.as_str(),
3797 prepared.helper_program().as_os_str().to_os_string(),
3798 launch.args.iter().map(OsString::from).collect(),
3799 env_entries,
3800 launch.scrub_env.iter().map(OsString::from).collect(),
3801 ).map_err(|_| failure(
3802 NodeFailureCode::HarnessMcpUnavailable,
3803 "harness MCP launch overlay is unavailable",
3804 ))?;
3805 control
3806 .install_native_mcp_server_launch_overlay(instance_id, overlay)
3807 .map_err(|_| failure(
3808 NodeFailureCode::HarnessMcpUnavailable,
3809 "harness MCP launch overlay could not be installed",
3810 ))?;
3811 tracing::info!(
3817 node_id = %self.node_id,
3818 instance_id = ?instance_id,
3819 provider = %provider,
3820 mode = ?mode,
3821 "harness MCP overlay installed for spawn",
3822 );
3823 Some(NativeHarnessMcpOverlayGuard { control, instance_id })
3824 } else {
3825 None
3826 };
3827 let prepared_materialization = self.prepare_session_materialization(
3828 &address,
3829 &provider,
3830 mode,
3831 environment_profile,
3832 bundle,
3833 context,
3834 managed_authority.cloned(),
3835 )?;
3836 let (environment_overlay, mut materialization_guard) = match prepared_materialization {
3837 Some((overlay, guard)) => (Some(overlay), Some(guard)),
3838 None => (None, None),
3839 };
3840 let materialization_id = materialization_guard
3841 .as_ref()
3842 .and_then(SessionMaterializationGuard::id)
3843 .cloned();
3844 let mut environment_selection = SpawnPreparer::select_environment(self,
3845 instance_id,
3846 &provider,
3847 mode,
3848 environment_profile,
3849 )?;
3850 let provider_home = materialization_guard
3853 .as_ref()
3854 .and_then(SessionMaterializationGuard::provider_home);
3855 let network_overlay_args = self.resolve_network_allowlist_overlay_args(
3856 &provider,
3857 mode,
3858 approval_level,
3859 network_allowlist,
3860 provider_home,
3861 )?;
3862 let mut network_overlay_args = network_overlay_args;
3863 if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
3864 network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
3865 }
3866 let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
3867 (prepared, args) if args.is_empty() => prepared,
3868 (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
3869 overlay.append_extra_args(args).map_err(|_| {
3870 failure(
3871 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3872 "network allowlist overlay could not merge with instance launch args",
3873 )
3874 })?;
3875 Some(PreparedNativeLaunchOverlay::Instance(overlay))
3876 }
3877 (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
3878 Some(PreparedNativeLaunchOverlay::Instance(
3879 overlay
3880 .into_instance_with_extra_args(args)
3881 .map_err(|_| {
3882 failure(
3883 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3884 "network allowlist overlay could not merge with environment overlay",
3885 )
3886 })?,
3887 ))
3888 }
3889 (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
3890 NativeInstanceLaunchOverlay::new(
3891 provider.clone(),
3892 match mode {
3893 SessionMode::Pty => TransportKind::Pty,
3894 SessionMode::Acp => TransportKind::Acp,
3895 SessionMode::Inline => TransportKind::Pipe,
3896 },
3897 Vec::new(),
3898 args,
3899 )
3900 .map_err(|_| {
3901 failure(
3902 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
3903 "network allowlist overlay is invalid for this transport",
3904 )
3905 })?,
3906 )),
3907 };
3908 #[cfg(unix)]
3911 let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
3913 (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
3914 });
3915 #[cfg(unix)]
3916 let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
3917 (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
3918 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3919 let uid = unsafe { libc_geteuid() };
3920 let handle = proxy.reserve(&session_key, m4a_provider, uid);
3921 let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
3922 tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
3923 Some(vec![
3924 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
3925 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
3926 gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
3927 ])
3928 }
3929 _ => None,
3930 };
3931 #[cfg(not(unix))]
3932 let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
3933 let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
3934 (SessionMode::Pty, _, _, Some(identity)) => {
3935 let merged = match environment_overlay {
3936 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
3937 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3938 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3939 }
3940 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3941 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3942 }
3943 }
3944 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
3945 Some(merged)
3946 }
3947 (SessionMode::Pty, Some(accounts), Some(m4a_provider), None) => {
3948 let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
3949 match accounts.provision(m4a_provider, &session_key) {
3950 Ok(account) => {
3951 let identity = account.env_mutations();
3952 let merged = match environment_overlay {
3953 None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3954 .map(PreparedNativeLaunchOverlay::Instance),
3955 Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3956 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3957 }
3958 Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3959 overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3960 }
3961 }
3962 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3963 tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3964 let mut merged = merged;
3965 match accounts.write_session_file(&account) {
3966 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3967 Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3968 Ok(args) if !args.is_empty() => {
3969 merged = match merged {
3970 PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3971 .append_extra_args(args)
3972 .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3973 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3974 PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3975 .into_instance_with_extra_args(args)
3976 .map(PreparedNativeLaunchOverlay::Instance)
3977 .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3978 };
3979 tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3980 }
3981 Ok(_) => {}
3982 Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3983 },
3984 }
3985 self.m4a_live
3986 .lock()
3987 .unwrap_or_else(|p| p.into_inner())
3988 .insert(account.nick.clone(), (instance_id, provider.clone()));
3989 Some(merged)
3990 }
3991 Err(error) => {
3992 tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3993 environment_overlay
3994 }
3995 }
3996 }
3997 (SessionMode::Pty, Some(_), None, _) => {
3998 tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
3999 environment_overlay
4000 }
4001 _ => environment_overlay,
4002 };
4003 let mut instance_overlay = environment_overlay
4004 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4005 .transpose()?
4006 .flatten();
4007 Ok(PreparedSpawn {
4008 instance_overlay,
4009 environment_selection,
4010 materialization_guard,
4011 harness_mcp_overlay,
4012 #[cfg(feature = "dig2-station-probe")]
4013 browser_station_lease_guard,
4014 materialization_id,
4015 })
4016 }
4017}
4018
4019struct SessionRecords {
4020 records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4021}
4022
4023struct SpawnIdempotencyCache {
4024 entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4025}
4026
4027struct SpawnIdempotencyEntry {
4028 value: SpawnIdempotencyValue,
4029 expires_at: Instant,
4030}
4031
4032enum SpawnIdempotencyValue {
4033 Standard {
4034 spec: SpawnSpec,
4035 result: Result<ResolvedSpawnReceipt, NodeFailure>,
4036 },
4037 Managed {
4038 request: ManagedWorktreeSpawnRequest,
4039 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4040 },
4041 HarnessMcp,
4042}
4043
4044enum DurableManagedSpawnReplayDecision {
4045 Reserved,
4046 PendingLinked,
4047 Committed(ManagedWorktreeSpawnReceipt),
4048}
4049
4050impl SpawnIdempotencyValue {
4051 fn references_context(&self, context_id: &SpawnContextId) -> bool {
4052 match self {
4053 Self::Standard { result, .. } => result
4054 .as_ref()
4055 .ok()
4056 .and_then(|receipt| receipt.context.as_ref()),
4057 Self::Managed { result, .. } => result
4058 .as_ref()
4059 .ok()
4060 .and_then(|receipt| receipt.spawn.context.as_ref()),
4061 Self::HarnessMcp => None,
4062 }
4063 .is_some_and(|receipt| &receipt.id == context_id)
4064 }
4065}
4066
4067#[derive(Clone, Copy)]
4068struct ControllerLease {
4069 connection_id: u64,
4070 expires_at: Instant,
4071}
4072
4073struct NodeEventHistory {
4074 last_sequence: u64,
4075 replay_floor_sequence: u64,
4076 events: VecDeque<NodeEventEnvelope>,
4077 record_providers: BTreeMap<SessionRecordId, AgentId>,
4078 removed_record_providers: BTreeMap<u64, AgentId>,
4079}
4080
4081impl NodeEventHistory {
4082 fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4083 Self {
4084 last_sequence: 0,
4085 replay_floor_sequence: 1,
4086 events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4087 record_providers,
4088 removed_record_providers: BTreeMap::new(),
4089 }
4090 }
4091}
4092
4093impl NodeShared {
4094 fn session_grok_leader_socket(
4098 &self,
4099 provider: &AgentId,
4100 mode: SessionMode,
4101 instance_id: AgentInstanceId,
4102 ) -> Option<std::path::PathBuf> {
4103 if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4104 return None;
4105 }
4106 let root = self
4107 .m4a_accounts
4108 .as_ref()
4109 .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4110 .unwrap_or_else(std::env::temp_dir);
4111 let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4112 if let Some(previous) = self
4113 .grok_leaders
4114 .lock()
4115 .unwrap_or_else(|p| p.into_inner())
4116 .insert(instance_id, socket.clone())
4117 {
4118 crate::grok_leader::retire(&previous);
4119 }
4120 Some(socket)
4121 }
4122
4123 fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4125 let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4126 if let Some(socket) = socket {
4127 crate::grok_leader::retire(&socket);
4128 }
4129 }
4130
4131 fn new_with_incarnation(
4132 handle: Gate4AgentHandle,
4133 access_token: String,
4134 node_id: NodeId,
4135 incarnation_id: NodeIncarnationId,
4136 workspaces: Vec<WorkspaceConfig>,
4137 enabled_providers: Vec<AgentId>,
4138 provider_runtime_statuses: ProviderRuntimeStatuses,
4139 provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4140 provider_contracts: Vec<ProviderContractSupport>,
4141 provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4142 spawn_profiles: SpawnProfileRegistry,
4143 native_launch_profile_control: Option<NativeLaunchProfileControl>,
4144 native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4145 session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4146 state_path: Option<PathBuf>,
4147 records: Vec<ManagedSessionRecord>,
4148 managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4149 managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4150 materializations: Vec<MaterializationOwnershipRecord>,
4151 persistence_warning: Option<String>,
4152 input_settle_timeout_ms: u64,
4153 ) -> Self {
4154 let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4155 let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4156 let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4157 let (harness_mcp_proxy_event_tx, _) =
4158 broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4159 let record_providers = records
4160 .iter()
4161 .map(|record| (record.record_id.clone(), record.provider.clone()))
4162 .collect();
4163 let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4164 .iter()
4165 .map(|workspace| {
4166 (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4167 })
4168 .collect();
4169 for lease in &managed_worktrees {
4170 worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4171 }
4172 let managed_worktree_profiles = workspaces.iter()
4173 .map(|workspace| (
4174 workspace.workspace_id.clone(),
4175 workspace.managed_worktree_profiles.clone(),
4176 ))
4177 .collect();
4178 let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4179 managed_worktrees,
4180 managed_worktree_tombstones,
4181 ).expect("durable managed worktree registry was validated while loading");
4182 managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4183 managed_worktrees.reattach_record_holders(
4184 &managed_worktree_record_holders(&records),
4185 unix_time_ms(),
4186 );
4187 Self {
4188 handle,
4189 access_token,
4190 node_id,
4191 incarnation_id,
4192 started_at_unix_ms: std::time::SystemTime::now()
4193 .duration_since(std::time::UNIX_EPOCH)
4194 .unwrap_or_default()
4195 .as_millis()
4196 .min(u64::MAX as u128) as u64,
4197 workspaces: RwLock::new(
4198 workspaces
4199 .into_iter()
4200 .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4201 .collect(),
4202 ),
4203 worktree_service_modes,
4204 managed_worktree_profiles,
4205 managed_worktrees: Mutex::new(managed_worktrees),
4206 enabled_providers,
4207 provider_runtime_statuses,
4208 provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4209 provider_runtime_monitor,
4210 provider_contracts,
4211 provider_adapter_contracts,
4212 spawn_profiles,
4213 environment_profiles: RwLock::new(BTreeMap::new()),
4214 environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4215 network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4216 #[cfg(feature = "dig2-station-probe")]
4217 browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4218 bundle_catalog: RwLock::new(BundleCatalog::default()),
4219 delivery_store: Mutex::new(None),
4220 context_pack_store: Mutex::new(None),
4221 context_catalog: RwLock::new(ContextPackCatalog::default()),
4222 native_launch_profile_control,
4223 harness_mcp_registry: None,
4224 native_session_catalog,
4225 session_environment_materializer,
4226 m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4227 m4a_live: Mutex::new(BTreeMap::new()),
4228 grok_leaders: Mutex::new(BTreeMap::new()),
4229 #[cfg(unix)]
4230 signer_proxy: std::sync::OnceLock::new(),
4231 materializations: Mutex::new(
4232 materializations
4233 .into_iter()
4234 .map(|record| (record.id().clone(), record))
4235 .collect(),
4236 ),
4237 spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4238 entries: BTreeMap::new(),
4239 }),
4240 managed_spawn_replays: Mutex::new(BTreeMap::new()),
4241 controller: Mutex::new(None),
4242 provisional_spawns: Mutex::new(BTreeMap::new()),
4243 pending_acp_options: Mutex::new(BTreeMap::new()),
4244 history: Mutex::new(NodeEventHistory::new(record_providers)),
4245 event_tx,
4246 terminal_event_tx,
4247 agent_stream_event_tx,
4248 harness_mcp_proxy_event_tx,
4249 terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4250 pty_input_wake: tokio::sync::Notify::new(),
4251 pty_input_marks: Mutex::new(BTreeMap::new()),
4252 next_connection_id: AtomicU64::new(1),
4253 next_instance_id: AtomicU64::new(1),
4254 next_command_id: AtomicU64::new(1),
4255 shutdown: AtomicBool::new(false),
4256 shutdown_notify: Notify::new(),
4257 preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4258 authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4259 inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4260 mutation_gate: AsyncMutex::new(()),
4261 session_bindings: Mutex::new(BTreeMap::new()),
4262 session_records: Mutex::new(SessionRecords {
4263 records: records
4264 .into_iter()
4265 .map(|record| (record.record_id.clone(), record))
4266 .collect(),
4267 }),
4268 state_path,
4269 persistence_error: RwLock::new(
4270 persistence_warning
4271 .map(|message| session_registry::sanitized_persistence_summary(&message)),
4272 ),
4273 state_transaction: Mutex::new(()),
4274 input_settle_timeout_ms,
4275 drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4276 runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4277 shell_efficiency_profile: OnceLock::new(),
4278 native_session_gauge: AtomicUsize::new(0),
4279 connection_loop_iterations: AtomicU64::new(0),
4280 connection_events_sent: AtomicU64::new(0),
4281 rejected_commands_total: AtomicU64::new(0),
4282 rejected_command_reasons: Mutex::new(BTreeMap::new()),
4283 drive_loop_iterations_total: AtomicU64::new(0),
4284 drive_loop_iterations_idle: AtomicU64::new(0),
4285 session_record_retention: SessionRecordRetentionConfig::default(),
4286 retired_records_total: AtomicU64::new(0),
4287 #[cfg(feature = "fixture")]
4288 fixture_semantic_hook_policy: false,
4289 #[cfg(feature = "fixture")]
4290 fixture_spawn_managed_worktree_v2_failure_probe:
4291 SpawnManagedWorktreeV2FailureProbe::default(),
4292 }
4293 }
4294
4295 #[cfg(test)]
4296 fn new(
4297 handle: Gate4AgentHandle,
4298 access_token: String,
4299 node_id: NodeId,
4300 workspaces: Vec<WorkspaceConfig>,
4301 enabled_providers: Vec<AgentId>,
4302 ) -> Self {
4303 Self::new_with_incarnation(
4304 handle,
4305 access_token,
4306 node_id,
4307 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4308 workspaces,
4309 enabled_providers,
4310 ProviderRuntimeStatuses::default(),
4311 None,
4312 Vec::new(),
4313 Vec::new(),
4314 SpawnProfileRegistry::default(),
4315 None,
4316 None,
4317 None,
4318 None,
4319 Vec::new(),
4320 Vec::new(),
4321 Vec::new(),
4322 Vec::new(),
4323 None,
4324 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4325 )
4326 }
4327
4328 fn persistence_error(&self) -> Option<String> {
4329 self.persistence_error
4330 .read()
4331 .unwrap_or_else(|poisoned| poisoned.into_inner())
4332 .clone()
4333 }
4334
4335 async fn admit_provider_runtime(
4336 &self,
4337 provider: &AgentId,
4338 requirement: ProviderRuntimeRequirement,
4339 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4340 let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4341 let pipe_transport = monitor.supports_pipe_transport(provider);
4342 let acp_transport = monitor.supports_acp_transport(provider);
4343 let provider = provider.clone();
4344 let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4345 let (status, admission) = timeout(
4346 Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4347 refresh,
4348 )
4349 .await
4350 .map_err(|_| failure(
4351 NodeFailureCode::BackendBusy,
4352 "provider runtime probe exceeded its bounded deadline",
4353 ))?
4354 .map_err(|_| failure(
4355 NodeFailureCode::BackendOperationFailed,
4356 "provider runtime probe task failed",
4357 ))?;
4358 if let Some(status) = status {
4359 self
4360 .provider_runtime_status_updates
4361 .write()
4362 .unwrap_or_else(|poisoned| poisoned.into_inner())
4363 .insert(status.provider().clone(), status);
4364 }
4365 admission.and_then(|policy| {
4366 require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4367 })
4368 } else {
4369 crate::provider_runtime::admit_status(
4375 &self.provider_runtime_statuses,
4376 provider,
4377 false,
4378 false,
4379 requirement,
4380 )
4381 };
4382 let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4383 admission.map_err(|error| match error {
4384 ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4385 NodeFailureCode::BackendOperationFailed,
4386 "provider launcher is unavailable",
4387 ),
4388 ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4389 NodeFailureCode::UnsupportedCapability,
4390 "provider semantic capability is not verified",
4391 ),
4392 ProviderRuntimeAdmissionError::ProbeBusy => failure(
4393 NodeFailureCode::BackendBusy,
4394 "provider runtime probe is already in progress",
4395 ),
4396 })
4397 }
4398
4399 fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4405 match self.provider_runtime_monitor.as_deref() {
4406 Some(monitor) => monitor.supports_pipe_transport(provider),
4407 None => false,
4408 }
4409 }
4410
4411 fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4417 match self.provider_runtime_monitor.as_deref() {
4418 Some(monitor) => monitor.supports_acp_transport(provider),
4419 None => false,
4420 }
4421 }
4422
4423 fn admit_declared_hook_semantics(
4439 &self,
4440 provider: &AgentId,
4441 policy: ProviderRuntimePolicy,
4442 ) -> ProviderRuntimePolicy {
4443 if policy.hook_semantics || !policy.raw_pty_lifecycle {
4444 return policy;
4445 }
4446 let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4447 contract.provider == *provider && contract.family == AdapterFamily::Hook
4448 });
4449 if !declares_hook {
4450 return policy;
4451 }
4452 ProviderRuntimePolicy::new(
4453 policy.raw_pty_lifecycle,
4454 policy.semantic_readiness,
4455 policy.structured_prompt,
4456 policy.provider_session_identity,
4457 policy.semantic_resume,
4458 true,
4459 )
4460 .expect("granting hook semantics over a raw-PTY policy is internally valid")
4461 }
4462
4463 fn resolve_network_allowlist_overlay_args(
4478 &self,
4479 provider: &AgentId,
4480 mode: SessionMode,
4481 approval_level: ApprovalLevel,
4482 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4483 provider_home: Option<&Path>,
4484 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4485 let Some(allowlist_id) = network_allowlist else {
4486 return Ok(Vec::new());
4487 };
4488 let catalog = self
4489 .network_allowlist_catalog
4490 .read()
4491 .unwrap_or_else(|poisoned| poisoned.into_inner());
4492 let Some(entry) = catalog.get(allowlist_id) else {
4493 return Err(failure(
4494 NodeFailureCode::UnknownNetworkAllowlist,
4495 "spawn network allowlist is unavailable on this node",
4496 ));
4497 };
4498
4499 if provider.as_str() == "claude" && !entry.permits.is_empty() {
4501 crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4502 |message| {
4503 failure(
4504 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4505 &message,
4506 )
4507 },
4508 )?;
4509 if !matches!(mode, SessionMode::Pty) {
4510 return Err(failure(
4511 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4512 "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4513 ));
4514 }
4515 let Some(home) = provider_home else {
4516 return Err(failure(
4517 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4518 "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4519 ));
4520 };
4521 let domains =
4522 crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4523 if domains.is_empty() {
4524 return Err(failure(
4525 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4526 "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4527 ));
4528 }
4529 let settings_path = home.join(
4530 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4531 );
4532 let json =
4533 crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4534 crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4535 |_| {
4536 failure(
4537 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4538 "Claude station network settings sidecar could not be written under ProviderHome",
4539 )
4540 },
4541 )?;
4542 let overlay =
4543 crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4544 return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4545 }
4546
4547 let Some(native) = entry.provider_native.as_ref() else {
4548 return Ok(Vec::new());
4549 };
4550 let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4551 provider.as_str(),
4552 approval_level,
4553 native,
4554 )
4555 .map_err(|message| {
4556 failure(
4557 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4558 &message,
4559 )
4560 })?;
4561 if overlay.is_empty() {
4562 return Ok(Vec::new());
4563 }
4564 if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4565 return Err(failure(
4566 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4567 "Codex network_access -c overlay requires PTY or ACP transport",
4568 ));
4569 }
4570 Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4571 }
4572
4573 fn resolve_codex_network_access_overlay_args(
4575 &self,
4576 provider: &AgentId,
4577 mode: SessionMode,
4578 approval_level: ApprovalLevel,
4579 network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4580 ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4581 self.resolve_network_allowlist_overlay_args(
4582 provider,
4583 mode,
4584 approval_level,
4585 network_allowlist,
4586 None,
4587 )
4588 }
4589
4590 fn resolve_environment_profile(
4591 &self,
4592 resolved: &ResolvedSpawnSpec,
4593 ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4594 if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4600 let catalog = self
4601 .network_allowlist_catalog
4602 .read()
4603 .unwrap_or_else(|poisoned| poisoned.into_inner());
4604 let Some(entry) = catalog.get(allowlist_id) else {
4605 return Err(failure(
4606 NodeFailureCode::UnknownNetworkAllowlist,
4607 "spawn network allowlist is unavailable on this node",
4608 ));
4609 };
4610 if let Some(native) = entry.provider_native.as_ref() {
4613 if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4614 resolved.provider.as_str(),
4615 resolved.approval_level,
4616 native,
4617 ) {
4618 return Err(failure(
4619 NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4620 &message,
4621 ));
4622 }
4623 }
4624 }
4625 #[cfg(feature = "dig2-station-probe")]
4634 if resolved.browser_profile_id.is_some() {
4635 use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4636 match probe_station_reachable() {
4637 Ok(()) => {}
4638 Err(StationProbeError::UnsupportedPlatform)
4639 | Err(StationProbeError::InvalidPipeSuffix) => {
4640 return Err(failure(
4641 NodeFailureCode::BrowserStationProbeUnavailable,
4642 "dig2browser station probe unavailable on this platform",
4643 ));
4644 }
4645 Err(StationProbeError::Unreachable) => {
4646 return Err(failure(
4647 NodeFailureCode::BrowserStationUnreachable,
4648 "dig2browser station named-pipe is unreachable",
4649 ));
4650 }
4651 }
4652 }
4653 let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4654 return Ok(None);
4655 };
4656 let profiles = self
4657 .environment_profiles
4658 .read()
4659 .unwrap_or_else(|poisoned| poisoned.into_inner());
4660 let binding = profiles.get(profile_id).ok_or_else(|| {
4661 failure(
4662 NodeFailureCode::UnknownEnvironmentProfile,
4663 "spawn environment profile is unavailable on this node",
4664 )
4665 })?;
4666 if binding.provider != resolved.provider
4667 || binding.native_profile_id(resolved.mode).is_none()
4668 {
4669 return Err(failure(
4670 NodeFailureCode::EnvironmentProfileBindingMismatch,
4671 "spawn environment profile does not match the provider and mode",
4672 ));
4673 }
4674 Ok(Some(ResolvedEnvironmentProfileReceipt {
4678 profile_id: binding.id.clone(),
4679 profile_revision: binding.revision.clone(),
4680 network_allowlist: resolved.network_allowlist.clone(),
4681 browser_profile_id: resolved.browser_profile_id.clone(),
4682 }))
4683 }
4684
4685 fn resolve_bundle(
4686 &self,
4687 resolved: &ResolvedSpawnSpec,
4688 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4689 ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4690 let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4691 return Ok(None);
4692 };
4693 let catalog = self.bundle_catalog
4694 .read()
4695 .unwrap_or_else(|poisoned| poisoned.into_inner());
4696 let bundle = catalog.get(bundle_id).ok_or_else(|| {
4697 failure(
4698 NodeFailureCode::UnknownBundle,
4699 "spawn bundle is unavailable on this node",
4700 )
4701 })?;
4702 self.bundle_layout(
4703 &resolved.provider,
4704 resolved.mode,
4705 environment_profile,
4706 bundle,
4707 )?;
4708 Ok(Some(bundle.receipt()))
4709 }
4710
4711 fn begin_delivery_stage(
4712 &self,
4713 manifest: DeliveryBundleManifestV2,
4714 ) -> Result<NodeResponse, NodeFailure> {
4715 let mut store = self
4716 .delivery_store
4717 .lock()
4718 .unwrap_or_else(|poisoned| poisoned.into_inner());
4719 let store = store.as_mut().ok_or_else(|| {
4720 failure(
4721 NodeFailureCode::DeliveryStageStorageFailed,
4722 "node delivery store is unavailable",
4723 )
4724 })?;
4725 let begun = store.begin(manifest).map_err(delivery_failure)?;
4726 Ok(NodeResponse::DeliveryStageBegun {
4727 stage_id: begun.stage_id,
4728 manifest_digest: begun.manifest_digest,
4729 missing_blobs: begun.missing_blobs,
4730 })
4731 }
4732
4733 fn put_delivery_blob_chunk(
4734 &self,
4735 stage_id: DeliveryStageId,
4736 blob_digest: DeliveryBlobDigestV1,
4737 offset: u64,
4738 chunk_hex: DeliveryBlobChunkHexV1,
4739 ) -> Result<NodeResponse, NodeFailure> {
4740 let mut store = self
4741 .delivery_store
4742 .lock()
4743 .unwrap_or_else(|poisoned| poisoned.into_inner());
4744 let store = store.as_mut().ok_or_else(|| {
4745 failure(
4746 NodeFailureCode::DeliveryStageStorageFailed,
4747 "node delivery store is unavailable",
4748 )
4749 })?;
4750 let accepted = store
4751 .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
4752 .map_err(delivery_failure)?;
4753 Ok(NodeResponse::DeliveryBlobChunkAccepted {
4754 stage_id: accepted.stage_id,
4755 blob_digest: accepted.blob_digest,
4756 next_offset: accepted.next_offset,
4757 })
4758 }
4759
4760 fn commit_delivery_stage(
4761 &self,
4762 stage_id: DeliveryStageId,
4763 ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
4764 let mut store = self
4765 .delivery_store
4766 .lock()
4767 .unwrap_or_else(|poisoned| poisoned.into_inner());
4768 let store = store.as_mut().ok_or_else(|| {
4769 failure(
4770 NodeFailureCode::DeliveryStageStorageFailed,
4771 "node delivery store is unavailable",
4772 )
4773 })?;
4774 let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
4775 let mut catalog = self
4776 .bundle_catalog
4777 .write()
4778 .unwrap_or_else(|poisoned| poisoned.into_inner());
4779 let mut next_catalog = catalog.clone();
4780 next_catalog
4781 .insert_idempotent(prepared.bundle().clone())
4782 .map_err(|_| {
4783 failure(
4784 NodeFailureCode::DeliveryStageConflict,
4785 "delivery bundle identity conflicts with installed content",
4786 )
4787 })?;
4788 let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
4789 *catalog = next_catalog;
4790 Ok(receipt)
4791 }
4792
4793 fn abort_delivery_stage(
4794 &self,
4795 stage_id: &DeliveryStageId,
4796 ) -> Result<(), NodeFailure> {
4797 let mut store = self
4798 .delivery_store
4799 .lock()
4800 .unwrap_or_else(|poisoned| poisoned.into_inner());
4801 let store = store.as_mut().ok_or_else(|| {
4802 failure(
4803 NodeFailureCode::DeliveryStageStorageFailed,
4804 "node delivery store is unavailable",
4805 )
4806 })?;
4807 store.abort(stage_id).map_err(delivery_failure)
4808 }
4809
4810 fn resolve_context(
4811 &self,
4812 resolved: &ResolvedSpawnSpec,
4813 ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
4814 let Some(context_id) = resolved.context_id.as_ref() else {
4815 return Ok(None);
4816 };
4817 self.context_catalog
4818 .read()
4819 .unwrap_or_else(|poisoned| poisoned.into_inner())
4820 .get(context_id)
4821 .map(|context| context.receipt().clone())
4822 .ok_or_else(|| {
4823 failure(
4824 NodeFailureCode::UnknownContextPack,
4825 "spawn context pack is unavailable on this node",
4826 )
4827 })
4828 .map(Some)
4829 }
4830
4831 fn bundle_layout(
4832 &self,
4833 provider: &AgentId,
4834 mode: SessionMode,
4835 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4836 bundle: &NodeBundle,
4837 ) -> Result<BundleProviderLayout, NodeFailure> {
4838 let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
4839 failure(
4840 NodeFailureCode::BundleBindingMismatch,
4841 "spawn bundle does not match the provider and mode",
4842 )
4843 })?;
4844 if layout != BundleProviderLayout::Codex {
4845 return Ok(layout);
4846 }
4847 let profile = environment_profile.and_then(|receipt| {
4848 let profiles = self
4849 .environment_profiles
4850 .read()
4851 .unwrap_or_else(|poisoned| poisoned.into_inner());
4852 let binding = profiles.get(&receipt.profile_id)?;
4853 if binding.revision != receipt.profile_revision
4854 || &binding.provider != provider
4855 || binding.native_profile_id(mode).is_none()
4856 {
4857 return None;
4858 }
4859 self.environment_materialization_profiles
4860 .read()
4861 .unwrap_or_else(|poisoned| poisoned.into_inner())
4862 .get(&receipt.profile_id)
4863 .cloned()
4864 });
4865 if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
4866 return Err(failure(
4867 NodeFailureCode::BundleBindingMismatch,
4868 "Codex bundle requires an exact isolated CODEX_HOME profile",
4869 ));
4870 }
4871 Ok(layout)
4872 }
4873
4874 fn select_environment_profile(
4875 &self,
4876 instance_id: AgentInstanceId,
4877 provider: &AgentId,
4878 mode: SessionMode,
4879 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4880 ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
4881 let Some(environment_profile) = environment_profile else {
4882 return Ok(None);
4883 };
4884 let native_profile_id = {
4885 let profiles = self
4886 .environment_profiles
4887 .read()
4888 .unwrap_or_else(|poisoned| poisoned.into_inner());
4889 let binding = profiles
4890 .get(&environment_profile.profile_id)
4891 .ok_or_else(|| {
4892 failure(
4893 NodeFailureCode::UnknownEnvironmentProfile,
4894 "session environment profile is unavailable on this node",
4895 )
4896 })?;
4897 if &binding.revision != &environment_profile.profile_revision
4898 || &binding.provider != provider
4899 {
4900 return Err(failure(
4901 NodeFailureCode::EnvironmentProfileBindingMismatch,
4902 "session environment profile revision or provider changed",
4903 ));
4904 }
4905 binding.native_profile_id(mode).cloned().ok_or_else(|| {
4906 failure(
4907 NodeFailureCode::EnvironmentProfileBindingMismatch,
4908 "session environment profile does not support this mode",
4909 )
4910 })?
4911 };
4912 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4913 failure(
4914 NodeFailureCode::BackendOperationFailed,
4915 "native environment profile controller is unavailable",
4916 )
4917 })?;
4918 control
4919 .select_native_launch_profile(instance_id, native_profile_id)
4920 .map_err(|_| {
4921 failure(
4922 NodeFailureCode::BackendOperationFailed,
4923 "native environment profile selection failed",
4924 )
4925 })?;
4926 Ok(Some(NativeEnvironmentSelectionGuard {
4927 control: Some(control),
4928 instance_id,
4929 }))
4930 }
4931
4932 fn install_prepared_launch_overlay(
4933 &self,
4934 instance_id: AgentInstanceId,
4935 overlay: PreparedNativeLaunchOverlay,
4936 ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
4937 let control = self.native_launch_profile_control.clone().ok_or_else(|| {
4938 failure(
4939 NodeFailureCode::BackendOperationFailed,
4940 "native launch profile controller is unavailable",
4941 )
4942 })?;
4943 match overlay {
4944 PreparedNativeLaunchOverlay::Environment(overlay) => {
4945 control
4946 .install_native_launch_environment_overlay(instance_id, overlay)
4947 .map_err(|_| failure(
4948 NodeFailureCode::BackendOperationFailed,
4949 "session environment overlay installation failed",
4950 ))?;
4951 Ok(None)
4952 }
4953 PreparedNativeLaunchOverlay::Instance(overlay) => {
4954 control
4955 .install_native_instance_launch_overlay(instance_id, overlay)
4956 .map_err(|_| failure(
4957 NodeFailureCode::BundleBindingMismatch,
4958 "session bundle launch overlay installation failed",
4959 ))?;
4960 Ok(Some(NativeInstanceOverlayGuard {
4961 control: Some(control),
4962 instance_id,
4963 }))
4964 }
4965 }
4966 }
4967
4968 fn environment_materialization_profile(
4969 &self,
4970 environment_profile: &ResolvedEnvironmentProfileReceipt,
4971 ) -> Option<NodeSessionMaterializationProfile> {
4972 self.environment_materialization_profiles
4973 .read()
4974 .unwrap_or_else(|poisoned| poisoned.into_inner())
4975 .get(&environment_profile.profile_id)
4976 .cloned()
4977 }
4978
4979 fn materialization_profile(
4980 &self,
4981 provider: &AgentId,
4982 mode: SessionMode,
4983 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4984 bundle: Option<&ResolvedBundleReceipt>,
4985 context: Option<&ResolvedContextPackReceipt>,
4986 ) -> Result<Option<(
4987 NodeSessionMaterializationProfile,
4988 bool,
4989 Option<BundleProviderLayout>,
4990 )>, NodeFailure> {
4991 let environment = environment_profile
4992 .and_then(|receipt| self.environment_materialization_profile(receipt));
4993 let (bundle, bundle_layout) = match bundle {
4994 Some(receipt) => {
4995 let catalog = self.bundle_catalog.read()
4996 .unwrap_or_else(|poisoned| poisoned.into_inner());
4997 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
4998 NodeFailureCode::UnknownBundle,
4999 "session bundle is unavailable on this node",
5000 ))?;
5001 if installed.receipt() != *receipt {
5002 return Err(failure(
5003 NodeFailureCode::BundleBindingMismatch,
5004 "session bundle revision or digest changed",
5005 ));
5006 }
5007 let layout = self.bundle_layout(
5008 provider,
5009 mode,
5010 environment_profile,
5011 installed,
5012 )?;
5013 (Some(installed.clone()), Some(layout))
5014 }
5015 None => (None, None),
5016 };
5017 let has_environment_overlay = environment.is_some() || context.is_some();
5018 let profile = match (environment, bundle.as_ref(), bundle_layout) {
5019 (None, None, None) => None,
5020 (Some(profile), None, None) => Some(Ok(profile)),
5021 (None, Some(bundle), Some(layout)) => {
5022 Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5023 }
5024 (Some(profile), Some(bundle), Some(layout)) => {
5025 Some(profile.with_bundle(bundle, layout))
5026 }
5027 _ => {
5028 return Err(failure(
5029 NodeFailureCode::BundleBindingMismatch,
5030 "session bundle provider layout is unavailable",
5031 ));
5032 }
5033 };
5034 let profile = profile
5035 .transpose()
5036 .map_err(|_| failure(
5037 NodeFailureCode::BundleMaterializationFailed,
5038 "session bundle materialization profile is invalid",
5039 ))?;
5040 let profile = match (profile, context) {
5041 (Some(profile), Some(receipt)) => {
5042 let catalog = self.context_catalog
5043 .read()
5044 .unwrap_or_else(|poisoned| poisoned.into_inner());
5045 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5046 NodeFailureCode::UnknownContextPack,
5047 "session context pack is unavailable on this node",
5048 ))?;
5049 if pack.receipt() != receipt {
5050 return Err(failure(
5051 NodeFailureCode::ContextPackMaterializationFailed,
5052 "session context pack identity changed",
5053 ));
5054 }
5055 profile.with_context(pack)
5056 }
5057 (None, Some(receipt)) => {
5058 let catalog = self.context_catalog
5059 .read()
5060 .unwrap_or_else(|poisoned| poisoned.into_inner());
5061 let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5062 NodeFailureCode::UnknownContextPack,
5063 "session context pack is unavailable on this node",
5064 ))?;
5065 if pack.receipt() != receipt {
5066 return Err(failure(
5067 NodeFailureCode::ContextPackMaterializationFailed,
5068 "session context pack identity changed",
5069 ));
5070 }
5071 NodeSessionMaterializationProfile::from_context(pack)
5072 }
5073 (Some(profile), None) => Ok(profile),
5074 (None, None) => return Ok(None),
5075 }
5076 .map_err(|_| failure(
5077 NodeFailureCode::ContextPackMaterializationFailed,
5078 "session context pack materialization profile is invalid",
5079 ))?;
5080 Ok(Some((profile, has_environment_overlay, bundle_layout)))
5081 }
5082
5083 fn prepare_session_materialization<'a>(
5084 &'a self,
5085 address: &SessionAddress,
5086 provider: &AgentId,
5087 mode: SessionMode,
5088 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5089 bundle: Option<&ResolvedBundleReceipt>,
5090 context: Option<&ResolvedContextPackReceipt>,
5091 managed_lease_id: Option<ManagedWorktreeLeaseId>,
5092 ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5093 let Some((profile, has_environment_overlay, bundle_layout)) =
5094 self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5095 else {
5096 return Ok(None);
5097 };
5098 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5099 failure(
5100 NodeFailureCode::BackendOperationFailed,
5101 "session environment materializer is unavailable",
5102 )
5103 })?;
5104 let materialization_failure_code = if context.is_some() {
5105 NodeFailureCode::ContextPackMaterializationFailed
5106 } else if bundle.is_some() {
5107 NodeFailureCode::BundleMaterializationFailed
5108 } else {
5109 NodeFailureCode::BackendOperationFailed
5110 };
5111 let id = MaterializationId::new(format!(
5112 "mat-{}-{}-{}",
5113 self.incarnation_id,
5114 address.session.instance_id.0,
5115 address.session.generation.0,
5116 ))
5117 .map_err(|_| {
5118 failure(
5119 NodeFailureCode::BackendOperationFailed,
5120 "session environment materialization identity failed",
5121 )
5122 })?;
5123 let owner = MaterializationOwner::Session {
5124 incarnation_id: self.incarnation_id,
5125 instance_id: address.session.instance_id,
5126 generation: address.session.generation,
5127 };
5128 let mut ownership = materializer
5129 .begin(
5130 id.clone(),
5131 environment_profile.cloned(),
5132 bundle.cloned(),
5133 context.cloned(),
5134 owner,
5135 managed_lease_id,
5136 &profile,
5137 unix_time_ms(),
5138 )
5139 .map_err(|_| {
5140 failure(
5141 materialization_failure_code,
5142 "session materialization preparation failed",
5143 )
5144 })?;
5145 {
5146 let _transaction = self
5147 .state_transaction
5148 .lock()
5149 .unwrap_or_else(|poisoned| poisoned.into_inner());
5150 let duplicate = self
5151 .materializations
5152 .lock()
5153 .unwrap_or_else(|poisoned| poisoned.into_inner())
5154 .insert(id.clone(), ownership.clone());
5155 if duplicate.is_some() {
5156 return Err(failure(
5157 NodeFailureCode::BackendOperationFailed,
5158 "session environment materialization identity conflict",
5159 ));
5160 }
5161 if let Err(error) = self.persist_state_locked() {
5162 self.materializations
5163 .lock()
5164 .unwrap_or_else(|poisoned| poisoned.into_inner())
5165 .remove(&id);
5166 return Err(persistence_failure(error));
5167 }
5168 }
5169 let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5170 Ok(environment) => environment,
5171 Err(_) => {
5172 let transaction = self
5173 .state_transaction
5174 .lock()
5175 .unwrap_or_else(|poisoned| poisoned.into_inner());
5176 self.materializations
5177 .lock()
5178 .unwrap_or_else(|poisoned| poisoned.into_inner())
5179 .insert(id.clone(), ownership.clone());
5180 if self.persist_state_locked().is_err() {
5181 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5182 }
5183 drop(transaction);
5184 if ownership.state() == MaterializationState::CleanupRequired {
5185 let _ = self.cleanup_materialization(&id);
5186 }
5187 return Err(failure(
5188 materialization_failure_code,
5189 "session materialization failed",
5190 ));
5191 }
5192 };
5193 {
5194 let _transaction = self
5195 .state_transaction
5196 .lock()
5197 .unwrap_or_else(|poisoned| poisoned.into_inner());
5198 self.materializations
5199 .lock()
5200 .unwrap_or_else(|poisoned| poisoned.into_inner())
5201 .insert(id.clone(), ownership.clone());
5202 if let Err(error) = self.persist_state_locked() {
5203 drop(_transaction);
5204 let _ = self.cleanup_materialization(&id);
5205 return Err(persistence_failure(error));
5206 }
5207 }
5208 let guard = SessionMaterializationGuard {
5209 shared: self,
5210 id: Some(id),
5211 provider_home: Some(ownership.provider_home().to_path_buf()),
5212 };
5213 let bundle_arguments = match bundle {
5214 Some(receipt) => {
5215 let catalog = self.bundle_catalog.read()
5216 .unwrap_or_else(|poisoned| poisoned.into_inner());
5217 let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5218 NodeFailureCode::UnknownBundle,
5219 "session bundle is unavailable on this node",
5220 ))?;
5221 if installed.receipt() != *receipt {
5222 return Err(failure(
5223 NodeFailureCode::BundleBindingMismatch,
5224 "session bundle revision or digest changed",
5225 ));
5226 }
5227 let layout = bundle_layout.ok_or_else(|| failure(
5228 NodeFailureCode::BundleBindingMismatch,
5229 "materialized bundle provider layout is unavailable",
5230 ))?;
5231 materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5232 failure(
5233 NodeFailureCode::BundleMaterializationFailed,
5234 "session bundle byte revalidation failed",
5235 )
5236 })?;
5237 Some(bundle_launch_arguments(
5238 layout,
5239 ownership.bundle_root(),
5240 ownership.provider_home(),
5241 ).map_err(|_| failure(
5242 NodeFailureCode::BundleBindingMismatch,
5243 "session bundle launch binding is invalid",
5244 ))?)
5245 }
5246 None => None,
5247 };
5248 if let Some(receipt) = context {
5249 materializer
5250 .revalidate_context(&ownership, receipt)
5251 .map_err(|_| failure(
5252 NodeFailureCode::ContextPackMaterializationFailed,
5253 "session context pack byte revalidation failed",
5254 ))?;
5255 }
5256 let transport = match mode {
5257 SessionMode::Pty => TransportKind::Pty,
5258 SessionMode::Inline => TransportKind::Pipe,
5259 SessionMode::Acp => TransportKind::Acp,
5260 };
5261 let overlay = if let Some(extra_args) = bundle_arguments {
5262 Some(PreparedNativeLaunchOverlay::Instance(
5263 NativeInstanceLaunchOverlay::new(
5264 provider.clone(),
5265 transport,
5266 if has_environment_overlay {
5267 environment
5268 } else {
5269 Vec::new()
5270 },
5271 extra_args,
5272 )
5273 .map_err(|_| {
5274 failure(
5275 NodeFailureCode::BundleBindingMismatch,
5276 "session bundle launch overlay is invalid",
5277 )
5278 })?,
5279 ))
5280 } else if has_environment_overlay {
5281 let context_only = environment_profile.is_none()
5282 && bundle.is_none()
5283 && context.is_some();
5284 let overlay = if context_only {
5285 NativeLaunchEnvironmentOverlay::new_context_root(
5286 provider.clone(),
5287 transport,
5288 environment,
5289 )
5290 .map_err(|_| {
5291 failure(
5292 NodeFailureCode::ContextPackMaterializationFailed,
5293 "context-only launch overlay is invalid",
5294 )
5295 })?
5296 } else {
5297 NativeLaunchEnvironmentOverlay::new(
5298 provider.clone(),
5299 transport,
5300 environment,
5301 )
5302 .map_err(|_| {
5303 failure(
5304 NodeFailureCode::BackendOperationFailed,
5305 "session environment overlay is invalid",
5306 )
5307 })?
5308 };
5309 Some(PreparedNativeLaunchOverlay::Environment(overlay))
5310 } else {
5311 None
5312 };
5313 Ok(Some((overlay, guard)))
5314 }
5315
5316 fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5317 let Some(mut ownership) = self
5318 .materializations
5319 .lock()
5320 .unwrap_or_else(|poisoned| poisoned.into_inner())
5321 .get(id)
5322 .cloned()
5323 else {
5324 return Ok(());
5325 };
5326 if ownership.state() != MaterializationState::CleanupRequired {
5327 ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5328 failure(
5329 NodeFailureCode::BackendOperationFailed,
5330 "session environment cleanup state is invalid",
5331 )
5332 })?;
5333 }
5334 {
5335 let _transaction = self
5336 .state_transaction
5337 .lock()
5338 .unwrap_or_else(|poisoned| poisoned.into_inner());
5339 let previous = self
5340 .materializations
5341 .lock()
5342 .unwrap_or_else(|poisoned| poisoned.into_inner())
5343 .insert(id.clone(), ownership.clone());
5344 if let Err(error) = self.persist_state_locked() {
5345 let mut materializations = self
5346 .materializations
5347 .lock()
5348 .unwrap_or_else(|poisoned| poisoned.into_inner());
5349 if let Some(previous) = previous {
5350 materializations.insert(id.clone(), previous);
5351 }
5352 return Err(persistence_failure(error));
5353 }
5354 }
5355 let cleanup = self
5356 .session_environment_materializer
5357 .as_ref()
5358 .ok_or(())
5359 .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5360 if cleanup.is_err() {
5361 let _ = ownership.mark_recovery_required(unix_time_ms());
5362 self.materializations
5363 .lock()
5364 .unwrap_or_else(|poisoned| poisoned.into_inner())
5365 .insert(id.clone(), ownership);
5366 if self.persist_state().is_err() {
5367 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5368 }
5369 return Err(failure(
5370 NodeFailureCode::BackendOperationFailed,
5371 "session environment cleanup requires recovery",
5372 ));
5373 }
5374 let _transaction = self
5375 .state_transaction
5376 .lock()
5377 .unwrap_or_else(|poisoned| poisoned.into_inner());
5378 self.materializations
5379 .lock()
5380 .unwrap_or_else(|poisoned| poisoned.into_inner())
5381 .remove(id);
5382 if let Err(error) = self.persist_state_locked() {
5383 self.materializations
5384 .lock()
5385 .unwrap_or_else(|poisoned| poisoned.into_inner())
5386 .insert(id.clone(), ownership);
5387 return Err(persistence_failure(error));
5388 }
5389 Ok(())
5390 }
5391
5392 fn resolve_record_materialization(
5393 &self,
5394 record_id: &SessionRecordId,
5395 provider: &AgentId,
5396 mode: SessionMode,
5397 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5398 bundle: Option<&ResolvedBundleReceipt>,
5399 context: Option<&ResolvedContextPackReceipt>,
5400 ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5401 let ownership = {
5402 let materializations = self
5403 .materializations
5404 .lock()
5405 .unwrap_or_else(|poisoned| poisoned.into_inner());
5406 let mut matches = materializations.values().filter(|record| {
5407 record.owner()
5408 == &MaterializationOwner::Record {
5409 record_id: record_id.clone(),
5410 }
5411 });
5412 let ownership = matches.next().cloned();
5413 if matches.next().is_some() {
5414 return Err(failure(
5415 NodeFailureCode::BackendOperationFailed,
5416 "managed session has conflicting environment ownership",
5417 ));
5418 }
5419 ownership
5420 };
5421 if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5422 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5423 failure(
5424 NodeFailureCode::ContextPackMaterializationFailed,
5425 "session context materializer is unavailable",
5426 )
5427 })?;
5428 let pack = materializer
5429 .revalidate_context(ownership, receipt)
5430 .map_err(|_| {
5431 self.mark_materialization_recovery_required(ownership.id());
5432 failure(
5433 NodeFailureCode::ContextPackMaterializationFailed,
5434 "managed session context pack revalidation failed",
5435 )
5436 })?;
5437 self.context_catalog
5438 .write()
5439 .unwrap_or_else(|poisoned| poisoned.into_inner())
5440 .insert(pack)
5441 .map_err(|_| failure(
5442 NodeFailureCode::ContextPackMaterializationFailed,
5443 "managed session context pack catalog restore failed",
5444 ))?;
5445 }
5446 let materialization = self.materialization_profile(
5447 provider,
5448 mode,
5449 environment_profile,
5450 bundle,
5451 context,
5452 )?;
5453 match (ownership, materialization) {
5454 (None, None) => Ok(None),
5455 (None, Some(_)) | (Some(_), None) => Err(failure(
5456 NodeFailureCode::BackendOperationFailed,
5457 "managed session materialization is unavailable",
5458 )),
5459 (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5460 if ownership.environment_profile() != environment_profile
5461 || ownership.bundle() != bundle
5462 || ownership.context() != context
5463 || ownership.state() != MaterializationState::Ready
5464 {
5465 self.mark_materialization_recovery_required(ownership.id());
5466 return Err(failure(
5467 NodeFailureCode::BackendOperationFailed,
5468 "managed session environment ownership requires recovery",
5469 ));
5470 }
5471 let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5472 failure(
5473 NodeFailureCode::BackendOperationFailed,
5474 "session environment materializer is unavailable",
5475 )
5476 })?;
5477 if materializer.revalidate(&ownership).is_err() {
5478 self.mark_materialization_recovery_required(ownership.id());
5479 return Err(failure(
5480 NodeFailureCode::BackendOperationFailed,
5481 "managed session environment revalidation failed",
5482 ));
5483 }
5484 let bundle_arguments = if let Some(bundle_receipt) = bundle {
5485 let catalog = self.bundle_catalog.read()
5486 .unwrap_or_else(|poisoned| poisoned.into_inner());
5487 let Some(installed) = catalog.get(&bundle_receipt.id) else {
5488 return Err(failure(
5489 NodeFailureCode::UnknownBundle,
5490 "managed session bundle is unavailable",
5491 ));
5492 };
5493 let layout = bundle_layout.ok_or_else(|| failure(
5494 NodeFailureCode::BundleBindingMismatch,
5495 "record bundle provider layout is unavailable",
5496 ))?;
5497 if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5498 drop(catalog);
5499 self.mark_materialization_recovery_required(ownership.id());
5500 return Err(failure(
5501 NodeFailureCode::BundleMaterializationFailed,
5502 "managed session bundle revalidation failed",
5503 ));
5504 }
5505 Some(bundle_launch_arguments(
5506 layout,
5507 ownership.bundle_root(),
5508 ownership.provider_home(),
5509 ).map_err(|_| failure(
5510 NodeFailureCode::BundleBindingMismatch,
5511 "managed session bundle launch binding is invalid",
5512 ))?)
5513 } else {
5514 None
5515 };
5516 if !has_environment_materialization {
5517 let overlay = bundle_arguments.map(|extra_args| {
5518 NativeInstanceLaunchOverlay::new(
5519 provider.clone(),
5520 TransportKind::Pty,
5521 Vec::new(),
5522 extra_args,
5523 )
5524 .map(PreparedNativeLaunchOverlay::Instance)
5525 .map_err(|_| failure(
5526 NodeFailureCode::BundleBindingMismatch,
5527 "managed session bundle launch overlay is invalid",
5528 ))
5529 }).transpose()?;
5530 return Ok(Some((ownership.id().clone(), overlay)));
5531 }
5532 let environment = match materializer.resolve_environment(&ownership, &profile) {
5533 Ok(environment) => environment,
5534 Err(
5535 SessionEnvironmentMaterializeError::SecretUnavailable
5536 | SessionEnvironmentMaterializeError::SecretDenied
5537 | SessionEnvironmentMaterializeError::InvalidSecretValue,
5538 ) => {
5539 return Err(failure(
5540 NodeFailureCode::BackendOperationFailed,
5541 "managed session environment resolution failed",
5542 ));
5543 }
5544 Err(
5545 SessionEnvironmentMaterializeError::InvalidRoot
5546 | SessionEnvironmentMaterializeError::OwnershipMismatch
5547 | SessionEnvironmentMaterializeError::Filesystem(_)
5548 | SessionEnvironmentMaterializeError::Record(_),
5549 ) => {
5550 self.mark_materialization_recovery_required(ownership.id());
5551 return Err(failure(
5552 NodeFailureCode::BackendOperationFailed,
5553 "managed session environment resolution failed",
5554 ));
5555 }
5556 };
5557 let transport = match mode {
5558 SessionMode::Pty => TransportKind::Pty,
5559 SessionMode::Inline => TransportKind::Pipe,
5560 SessionMode::Acp => TransportKind::Acp,
5561 };
5562 let overlay = match bundle_arguments {
5563 Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5564 NativeInstanceLaunchOverlay::new(
5565 provider.clone(),
5566 transport,
5567 environment,
5568 extra_args,
5569 )
5570 .map_err(|_| failure(
5571 NodeFailureCode::BundleBindingMismatch,
5572 "managed session bundle launch overlay is invalid",
5573 ))?,
5574 ),
5575 None => PreparedNativeLaunchOverlay::Environment(
5576 NativeLaunchEnvironmentOverlay::new(
5577 provider.clone(),
5578 transport,
5579 environment,
5580 )
5581 .map_err(|_| {
5582 failure(
5583 NodeFailureCode::BackendOperationFailed,
5584 "managed session environment overlay is invalid",
5585 )
5586 })?,
5587 ),
5588 };
5589 Ok(Some((ownership.id().clone(), Some(overlay))))
5590 }
5591 }
5592 }
5593
5594 fn destroy_record_materialization(
5595 &self,
5596 record_id: &SessionRecordId,
5597 ) -> Result<bool, NodeFailure> {
5598 let ids = self
5599 .materializations
5600 .lock()
5601 .unwrap_or_else(|poisoned| poisoned.into_inner())
5602 .values()
5603 .filter_map(|record| {
5604 (record.owner()
5605 == &MaterializationOwner::Record {
5606 record_id: record_id.clone(),
5607 })
5608 .then(|| record.id().clone())
5609 })
5610 .collect::<Vec<_>>();
5611 if ids.len() > 1 {
5612 return Err(failure(
5613 NodeFailureCode::BackendOperationFailed,
5614 "managed session has conflicting environment ownership",
5615 ));
5616 }
5617 let Some(id) = ids.first().cloned() else {
5618 return Ok(false);
5619 };
5620 let (terminal_record, mut cleanup_ownership) = {
5621 let _transaction = self
5622 .state_transaction
5623 .lock()
5624 .unwrap_or_else(|poisoned| poisoned.into_inner());
5625 let original_record = self.record(record_id)?;
5626 let mut terminal_record = original_record.clone();
5627 terminal_record.provider_session = None;
5628 terminal_record.active_session = None;
5629 terminal_record.state = ManagedSessionState::Unavailable;
5630 terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5631 terminal_record.updated_at_unix_ms = unix_time_ms();
5632 let original_ownership = self
5633 .materializations
5634 .lock()
5635 .unwrap_or_else(|poisoned| poisoned.into_inner())
5636 .get(&id)
5637 .cloned()
5638 .ok_or_else(|| {
5639 failure(
5640 NodeFailureCode::BackendOperationFailed,
5641 "managed session environment ownership disappeared during cleanup",
5642 )
5643 })?;
5644 let mut cleanup_ownership = original_ownership.clone();
5645 if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5646 cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5647 failure(
5648 NodeFailureCode::BackendOperationFailed,
5649 "managed session environment cleanup state is invalid",
5650 )
5651 })?;
5652 }
5653 self.session_records
5654 .lock()
5655 .unwrap_or_else(|poisoned| poisoned.into_inner())
5656 .records
5657 .insert(record_id.clone(), terminal_record.clone());
5658 self.materializations
5659 .lock()
5660 .unwrap_or_else(|poisoned| poisoned.into_inner())
5661 .insert(id.clone(), cleanup_ownership.clone());
5662 if let Err(error) = self.persist_state_locked() {
5663 self.session_records
5664 .lock()
5665 .unwrap_or_else(|poisoned| poisoned.into_inner())
5666 .records
5667 .insert(record_id.clone(), original_record);
5668 self.materializations
5669 .lock()
5670 .unwrap_or_else(|poisoned| poisoned.into_inner())
5671 .insert(id.clone(), original_ownership);
5672 return Err(persistence_failure(error));
5673 }
5674 (terminal_record, cleanup_ownership)
5675 };
5676 let cleanup = self
5677 .session_environment_materializer
5678 .as_ref()
5679 .ok_or(())
5680 .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5681 if cleanup.is_err() {
5682 let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5683 self.materializations
5684 .lock()
5685 .unwrap_or_else(|poisoned| poisoned.into_inner())
5686 .insert(id.clone(), cleanup_ownership);
5687 if self.persist_state().is_err() {
5688 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5689 }
5690 return Err(failure(
5691 NodeFailureCode::BackendOperationFailed,
5692 "managed session environment cleanup requires recovery",
5693 ));
5694 }
5695 let _transaction = self
5696 .state_transaction
5697 .lock()
5698 .unwrap_or_else(|poisoned| poisoned.into_inner());
5699 self.remove_record_memory(record_id);
5700 self.materializations
5701 .lock()
5702 .unwrap_or_else(|poisoned| poisoned.into_inner())
5703 .remove(&id);
5704 if let Err(error) = self.persist_state_locked() {
5705 self.session_records
5706 .lock()
5707 .unwrap_or_else(|poisoned| poisoned.into_inner())
5708 .records
5709 .insert(record_id.clone(), terminal_record);
5710 self.materializations
5711 .lock()
5712 .unwrap_or_else(|poisoned| poisoned.into_inner())
5713 .insert(id, cleanup_ownership);
5714 return Err(persistence_failure(error));
5715 }
5716 Ok(true)
5717 }
5718
5719 fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
5720 let changed = {
5721 let mut materializations = self
5722 .materializations
5723 .lock()
5724 .unwrap_or_else(|poisoned| poisoned.into_inner());
5725 let Some(ownership) = materializations.get_mut(id) else {
5726 return;
5727 };
5728 ownership.mark_recovery_required(unix_time_ms()).is_ok()
5729 };
5730 if changed && self.persist_state().is_err() {
5731 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5732 }
5733 }
5734
5735 fn reconcile_materializations(&self) {
5736 let records = self
5737 .materializations
5738 .lock()
5739 .unwrap_or_else(|poisoned| poisoned.into_inner())
5740 .values()
5741 .cloned()
5742 .collect::<Vec<_>>();
5743 for mut ownership in records {
5744 let result = match (ownership.state(), ownership.owner()) {
5745 (MaterializationState::Preparing, _)
5746 | (MaterializationState::CleanupRequired, _) => {
5747 self.cleanup_materialization(ownership.id())
5748 }
5749 (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
5750 if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
5751 self.materializations
5752 .lock()
5753 .unwrap_or_else(|poisoned| poisoned.into_inner())
5754 .insert(ownership.id().clone(), ownership.clone());
5755 self.persist_state().map_err(persistence_failure)
5756 } else {
5757 Err(failure(
5758 NodeFailureCode::BackendOperationFailed,
5759 "session environment reconciliation failed",
5760 ))
5761 }
5762 }
5763 (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
5764 .session_environment_materializer
5765 .as_ref()
5766 .ok_or_else(|| {
5767 failure(
5768 NodeFailureCode::BackendOperationFailed,
5769 "session environment materializer is unavailable",
5770 )
5771 })
5772 .and_then(|materializer| {
5773 materializer.revalidate(&ownership).map_err(|_| failure(
5774 NodeFailureCode::BackendOperationFailed,
5775 "session materialization revalidation failed",
5776 ))?;
5777 let record = self.record(record_id)?;
5778 if record.environment_profile.as_ref() != ownership.environment_profile()
5779 || record.bundle.as_ref() != ownership.bundle()
5780 || record.context.as_ref() != ownership.context()
5781 {
5782 return Err(failure(
5783 NodeFailureCode::BackendOperationFailed,
5784 "session materialization ownership changed after restart",
5785 ));
5786 }
5787 if let Some(receipt) = ownership.bundle() {
5788 let catalog = self.bundle_catalog.read()
5789 .unwrap_or_else(|poisoned| poisoned.into_inner());
5790 let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
5791 NodeFailureCode::UnknownBundle,
5792 "session bundle is unavailable after restart",
5793 ))?;
5794 if bundle.receipt() != *receipt {
5795 return Err(failure(
5796 NodeFailureCode::BundleBindingMismatch,
5797 "session bundle changed after restart",
5798 ));
5799 }
5800 let layout = self.bundle_layout(
5801 &record.provider,
5802 record.mode,
5803 record.environment_profile.as_ref(),
5804 bundle,
5805 )?;
5806 materializer
5807 .revalidate_bundle(&ownership, bundle, layout)
5808 .map_err(|_| failure(
5809 NodeFailureCode::BundleMaterializationFailed,
5810 "session bundle revalidation failed",
5811 ))?;
5812 }
5813 if let Some(receipt) = ownership.context() {
5814 let pack = materializer
5815 .revalidate_context(&ownership, receipt)
5816 .map_err(|_| {
5817 failure(
5818 NodeFailureCode::ContextPackMaterializationFailed,
5819 "session context revalidation failed",
5820 )
5821 })?;
5822 self.context_catalog
5823 .write()
5824 .unwrap_or_else(|poisoned| poisoned.into_inner())
5825 .insert(pack)
5826 .map_err(|_| {
5827 failure(
5828 NodeFailureCode::ContextPackMaterializationFailed,
5829 "session context catalog restore failed",
5830 )
5831 })?;
5832 }
5833 Ok(())
5834 }),
5835 (MaterializationState::RecoveryRequired, _) => Ok(()),
5836 };
5837 if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
5838 let _ = ownership.mark_recovery_required(unix_time_ms());
5839 self.materializations
5840 .lock()
5841 .unwrap_or_else(|poisoned| poisoned.into_inner())
5842 .insert(ownership.id().clone(), ownership);
5843 if self.persist_state().is_err() {
5844 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5845 }
5846 }
5847 }
5848 let materialized_profiles = self
5849 .environment_materialization_profiles
5850 .read()
5851 .unwrap_or_else(|poisoned| poisoned.into_inner())
5852 .keys()
5853 .cloned()
5854 .collect::<Vec<_>>();
5855 let ownership = self
5856 .materializations
5857 .lock()
5858 .unwrap_or_else(|poisoned| poisoned.into_inner())
5859 .values()
5860 .cloned()
5861 .collect::<Vec<_>>();
5862 let changed = {
5863 let mut session_records = self
5864 .session_records
5865 .lock()
5866 .unwrap_or_else(|poisoned| poisoned.into_inner());
5867 let mut changed = false;
5868 for record in session_records.records.values_mut() {
5869 let environment_profile = record.environment_profile.as_ref();
5870 if environment_profile.is_none()
5871 && record.bundle.is_none()
5872 && record.context.is_none()
5873 {
5874 continue;
5875 }
5876 if record.bundle.is_none()
5877 && environment_profile.is_some_and(|profile| {
5878 !materialized_profiles.contains(&profile.profile_id)
5879 })
5880 {
5881 continue;
5882 }
5883 let has_exact_ownership = ownership.iter().any(|candidate| {
5884 candidate.state() == MaterializationState::Ready
5885 && candidate.environment_profile() == environment_profile
5886 && candidate.bundle() == record.bundle.as_ref()
5887 && candidate.context() == record.context.as_ref()
5888 && candidate.owner()
5889 == &MaterializationOwner::Record {
5890 record_id: record.record_id.clone(),
5891 }
5892 });
5893 if !has_exact_ownership {
5894 record.provider_session = None;
5895 record.active_session = None;
5896 record.state = ManagedSessionState::Unavailable;
5897 record.last_error = Some(if record.bundle.is_some() {
5898 "bundle-unavailable".to_owned()
5899 } else if record.context.is_some() {
5900 "context-unavailable".to_owned()
5901 } else {
5902 "environment-profile-unavailable".to_owned()
5903 });
5904 record.updated_at_unix_ms = unix_time_ms();
5905 changed = true;
5906 }
5907 }
5908 changed
5909 };
5910 if changed && self.persist_state().is_err() {
5911 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5912 }
5913 }
5914
5915 fn replay_spawn_spec(
5916 &self,
5917 spec: &SpawnSpec,
5918 now: Instant,
5919 ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
5920 let mut cache = self
5921 .spawn_idempotency
5922 .lock()
5923 .unwrap_or_else(|poisoned| poisoned.into_inner());
5924 cache.entries.retain(|_, entry| entry.expires_at > now);
5925 if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
5926 let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
5927 return Err(failure(
5928 NodeFailureCode::SpawnIdempotencyConflict,
5929 "spawn idempotency key was reused across spawn kinds",
5930 ));
5931 };
5932 if existing != spec {
5933 return Err(failure(
5934 NodeFailureCode::SpawnIdempotencyConflict,
5935 "spawn idempotency key was reused with a different specification",
5936 ));
5937 }
5938 return Ok(Some(result.clone()));
5939 }
5940 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5941 return Err(failure(
5942 NodeFailureCode::SpawnIdempotencyCapacity,
5943 "spawn idempotency cache reached its bounded capacity",
5944 ));
5945 }
5946 Ok(None)
5947 }
5948
5949 fn remember_spawn_spec(
5950 &self,
5951 spec: SpawnSpec,
5952 result: Result<ResolvedSpawnReceipt, NodeFailure>,
5953 accepted_at: Instant,
5954 ) {
5955 self
5956 .spawn_idempotency
5957 .lock()
5958 .unwrap_or_else(|poisoned| poisoned.into_inner())
5959 .entries
5960 .insert(
5961 spec.idempotency_key.clone(),
5962 SpawnIdempotencyEntry {
5963 value: SpawnIdempotencyValue::Standard { spec, result },
5964 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5965 },
5966 );
5967 }
5968
5969 fn claim_harness_mcp_spawn(
5970 &self,
5971 _reservation_id: &HarnessMcpReservationId,
5972 _activation_digest: &HarnessMcpActivationDigest,
5973 spec: &SpawnSpec,
5974 now: Instant,
5975 ) -> Result<(), NodeFailure> {
5976 let mut cache = self.spawn_idempotency.lock()
5977 .unwrap_or_else(|poisoned| poisoned.into_inner());
5978 cache.entries.retain(|_, entry| entry.expires_at > now);
5979 if cache.entries.contains_key(&spec.idempotency_key) {
5980 return Err(failure(
5981 NodeFailureCode::SpawnIdempotencyConflict,
5982 "spawn idempotency key was reused across spawn kinds",
5983 ));
5984 }
5985 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
5986 return Err(failure(
5987 NodeFailureCode::SpawnIdempotencyCapacity,
5988 "spawn idempotency cache reached its bounded capacity",
5989 ));
5990 }
5991 cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
5992 value: SpawnIdempotencyValue::HarnessMcp,
5993 expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
5994 });
5995 Ok(())
5996 }
5997
5998 fn replay_managed_spawn(
5999 &self,
6000 request: &ManagedWorktreeSpawnRequest,
6001 now: Instant,
6002 ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6003 let mut cache = self.spawn_idempotency.lock()
6004 .unwrap_or_else(|poisoned| poisoned.into_inner());
6005 cache.entries.retain(|_, entry| entry.expires_at > now);
6006 if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6007 let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6008 return Err(failure(
6009 NodeFailureCode::SpawnIdempotencyConflict,
6010 "spawn idempotency key was reused across spawn kinds",
6011 ));
6012 };
6013 if existing != request {
6014 return Err(failure(
6015 NodeFailureCode::SpawnIdempotencyConflict,
6016 "spawn idempotency key was reused with a different managed specification",
6017 ));
6018 }
6019 return Ok(Some(result.clone()));
6020 }
6021 if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6022 return Err(failure(
6023 NodeFailureCode::SpawnIdempotencyCapacity,
6024 "spawn idempotency cache reached its bounded capacity",
6025 ));
6026 }
6027 Ok(None)
6028 }
6029
6030 fn remember_managed_spawn(
6031 &self,
6032 request: ManagedWorktreeSpawnRequest,
6033 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6034 accepted_at: Instant,
6035 ) {
6036 self.spawn_idempotency.lock()
6037 .unwrap_or_else(|poisoned| poisoned.into_inner())
6038 .entries
6039 .insert(
6040 request.spawn_spec.idempotency_key.clone(),
6041 SpawnIdempotencyEntry {
6042 value: SpawnIdempotencyValue::Managed { request, result },
6043 expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6044 },
6045 );
6046 }
6047
6048 fn remember_managed_spawn_attempt(
6049 &self,
6050 durable_v2_key: Option<&SpawnIdempotencyKey>,
6051 request: ManagedWorktreeSpawnRequest,
6052 result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6053 accepted_at: Instant,
6054 ) {
6055 if durable_v2_key.is_none() {
6056 self.remember_managed_spawn(request, result, accepted_at);
6057 }
6058 }
6059
6060 fn acquire_durable_managed_spawn_v2(
6061 &self,
6062 request: &ManagedWorktreeSpawnRequestV2,
6063 request_digest: String,
6064 ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6065 if self.state_path.is_none() {
6066 return Err(failure(
6067 NodeFailureCode::BackendOperationFailed,
6068 "managed worktree V2 requires durable node state",
6069 ));
6070 }
6071 let _transaction = self.state_transaction
6072 .lock()
6073 .unwrap_or_else(|poisoned| poisoned.into_inner());
6074 let key = request.spawn_spec.idempotency_key.clone();
6075 let mut previous = None;
6076 {
6077 let mut replays = self.managed_spawn_replays
6078 .lock()
6079 .unwrap_or_else(|poisoned| poisoned.into_inner());
6080 if let Some(record) = replays.get_mut(&key) {
6081 if record.request_digest != request_digest {
6082 return Err(failure(
6083 NodeFailureCode::SpawnIdempotencyConflict,
6084 "managed worktree V2 idempotency key was reused with a different request",
6085 ));
6086 }
6087 match &record.state {
6088 ManagedWorktreeSpawnReplayStateV10::Pending {
6089 owner_incarnation_id,
6090 lease_id: None,
6091 } if owner_incarnation_id != &self.incarnation_id => {
6092 previous = Some(record.clone());
6093 record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6094 owner_incarnation_id: self.incarnation_id,
6095 lease_id: None,
6096 };
6097 record.updated_at_unix_ms = unix_time_ms();
6098 }
6099 ManagedWorktreeSpawnReplayStateV10::Pending {
6100 lease_id: None,
6101 ..
6102 } => {
6103 return Err(failure(
6104 NodeFailureCode::BackendBusy,
6105 "managed worktree V2 reservation is already active",
6106 ));
6107 }
6108 ManagedWorktreeSpawnReplayStateV10::Pending {
6109 lease_id: Some(_),
6110 ..
6111 } => {
6112 return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6113 }
6114 ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6115 return Ok(DurableManagedSpawnReplayDecision::Committed(
6116 receipt.clone(),
6117 ));
6118 }
6119 }
6120 } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6121 return Err(failure(
6122 NodeFailureCode::SpawnIdempotencyCapacity,
6123 "durable managed worktree replay ledger reached its bounded capacity",
6124 ));
6125 } else {
6126 let now = unix_time_ms();
6127 replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6128 idempotency_key: key.clone(),
6129 request_digest,
6130 source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6131 profile_id: request.worktree_profile_id.clone(),
6132 expected_profile_revision: request.expected_profile_revision.clone(),
6133 state: ManagedWorktreeSpawnReplayStateV10::Pending {
6134 owner_incarnation_id: self.incarnation_id,
6135 lease_id: None,
6136 },
6137 created_at_unix_ms: now,
6138 updated_at_unix_ms: now,
6139 });
6140 }
6141 }
6142 if let Err(error) = self.persist_state_locked() {
6143 let mut replays = self.managed_spawn_replays
6144 .lock()
6145 .unwrap_or_else(|poisoned| poisoned.into_inner());
6146 match previous {
6147 Some(previous) => {
6148 replays.insert(key, previous);
6149 }
6150 None => {
6151 replays.remove(&key);
6152 }
6153 }
6154 return Err(persistence_failure(error));
6155 }
6156 Ok(DurableManagedSpawnReplayDecision::Reserved)
6157 }
6158
6159 fn link_durable_managed_spawn_v2_lease_locked(
6160 &self,
6161 key: &SpawnIdempotencyKey,
6162 lease_id: &ManagedWorktreeLeaseId,
6163 ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6164 let mut replays = self.managed_spawn_replays
6165 .lock()
6166 .unwrap_or_else(|poisoned| poisoned.into_inner());
6167 let record = replays.get_mut(key).ok_or_else(|| {
6168 failure(
6169 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6170 "managed worktree V2 reservation disappeared before allocation",
6171 )
6172 })?;
6173 let previous = record.clone();
6174 match &mut record.state {
6175 ManagedWorktreeSpawnReplayStateV10::Pending {
6176 lease_id: current,
6177 ..
6178 }
6179 if current.is_none() =>
6180 {
6181 *current = Some(lease_id.clone());
6182 record.updated_at_unix_ms = unix_time_ms();
6183 Ok(previous)
6184 }
6185 _ => Err(failure(
6186 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6187 "managed worktree V2 reservation is not allocation-ready",
6188 )),
6189 }
6190 }
6191
6192 fn restore_durable_managed_spawn_v2_locked(
6193 &self,
6194 key: &SpawnIdempotencyKey,
6195 previous: ManagedWorktreeSpawnReplayRecordV10,
6196 ) {
6197 self.managed_spawn_replays
6198 .lock()
6199 .unwrap_or_else(|poisoned| poisoned.into_inner())
6200 .insert(key.clone(), previous);
6201 }
6202
6203 fn commit_durable_managed_spawn_v2(
6204 &self,
6205 request: &ManagedWorktreeSpawnRequestV2,
6206 receipt: ManagedWorktreeSpawnReceipt,
6207 ) -> Result<(), NodeFailure> {
6208 let _transaction = self.state_transaction
6209 .lock()
6210 .unwrap_or_else(|poisoned| poisoned.into_inner());
6211 let key = &request.spawn_spec.idempotency_key;
6212 let previous = {
6213 let mut replays = self.managed_spawn_replays
6214 .lock()
6215 .unwrap_or_else(|poisoned| poisoned.into_inner());
6216 let record = replays.get_mut(key).ok_or_else(|| {
6217 failure(
6218 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6219 "managed worktree V2 reservation disappeared before commit",
6220 )
6221 })?;
6222 let previous = record.clone();
6223 match &record.state {
6224 ManagedWorktreeSpawnReplayStateV10::Pending {
6225 lease_id: Some(lease_id),
6226 ..
6227 } if lease_id == &receipt.lease.lease_id => {}
6228 _ => {
6229 return Err(failure(
6230 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6231 "managed worktree V2 reservation is not linked to the spawned lease",
6232 ));
6233 }
6234 }
6235 record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6236 record.updated_at_unix_ms = unix_time_ms();
6237 previous
6238 };
6239 if let Err(error) = self.persist_state_locked() {
6240 self.restore_durable_managed_spawn_v2_locked(key, previous);
6241 return Err(persistence_failure(error));
6242 }
6243 Ok(())
6244 }
6245
6246 fn remove_unallocated_durable_managed_spawn_v2(
6247 &self,
6248 request: &ManagedWorktreeSpawnRequestV2,
6249 ) -> Result<(), NodeFailure> {
6250 let _transaction = self.state_transaction
6251 .lock()
6252 .unwrap_or_else(|poisoned| poisoned.into_inner());
6253 let key = &request.spawn_spec.idempotency_key;
6254 let removed = {
6255 let mut replays = self.managed_spawn_replays
6256 .lock()
6257 .unwrap_or_else(|poisoned| poisoned.into_inner());
6258 match replays.get(key).map(|record| &record.state) {
6259 Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6260 lease_id: None,
6261 ..
6262 }) => {
6263 replays.remove(key)
6264 }
6265 _ => None,
6266 }
6267 };
6268 let Some(removed) = removed else {
6269 return Ok(());
6270 };
6271 if let Err(error) = self.persist_state_locked() {
6272 self.managed_spawn_replays
6273 .lock()
6274 .unwrap_or_else(|poisoned| poisoned.into_inner())
6275 .insert(key.clone(), removed);
6276 return Err(persistence_failure(error));
6277 }
6278 Ok(())
6279 }
6280
6281 fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6282 if spec.target.node_id != self.node_id {
6283 return Err(failure(
6284 NodeFailureCode::SpawnTargetMismatch,
6285 "spawn target does not match this node",
6286 ));
6287 }
6288 let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6289 failure(
6290 NodeFailureCode::UnknownSpawnProfile,
6291 "spawn profile is unavailable on this node",
6292 )
6293 })?;
6294 let resolved = spec.resolve(defaults).map_err(|error| match error {
6295 SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6296 NodeFailureCode::SpawnProfileRevisionMismatch,
6297 "spawn profile revision does not match the loaded profile",
6298 ),
6299 _ => failure(
6300 NodeFailureCode::InvalidRequest,
6301 "spawn specification could not be resolved",
6302 ),
6303 })?;
6304 if resolved.target.worktree_id.is_some() {
6305 self.require_worktree_service(&resolved.target.workspace_id)?;
6306 }
6307 let environment_profile = self.resolve_environment_profile(&resolved)?;
6308 self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6309 self.resolve_context(&resolved)?;
6310 Ok(resolved)
6311 }
6312
6313 async fn resolve_spawn_workspace(
6314 &self,
6315 resolved: &ResolvedSpawnSpec,
6316 deadline: Instant,
6317 ) -> Result<WorkspaceId, NodeFailure> {
6318 let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6319 return Ok(resolved.target.workspace_id.clone());
6320 };
6321 if self.managed_worktrees.lock()
6322 .unwrap_or_else(|poisoned| poisoned.into_inner())
6323 .lease_for_workspace(worktree_id)
6324 .is_some()
6325 {
6326 return Err(failure(
6327 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6328 "managed worktree targets may only be spawned through their lease request",
6329 ));
6330 }
6331 self.require_worktree_service(&resolved.target.workspace_id)?;
6332 let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6333 let selected_root = self.workspace_root(worktree_id)?;
6334 let remaining = spawn_deadline_remaining(deadline)?;
6335 let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6336 .await
6337 .map_err(|_| {
6338 failure(
6339 NodeFailureCode::SpawnDeadlineExceeded,
6340 "Git worktree selection exceeded the spawn deadline",
6341 )
6342 })?
6343 .map_err(git_worktree_failure)?;
6344 validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6345 Ok(worktree_id.clone())
6346 }
6347
6348 async fn spawn_from_spec(
6349 &self,
6350 spec: SpawnSpec,
6351 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6352 self.spawn_from_spec_with_proxy(spec, None).await
6353 }
6354
6355 async fn spawn_from_spec_with_proxy(
6356 &self,
6357 spec: SpawnSpec,
6358 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6359 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6360 let accepted_at = Instant::now();
6361 if harness_mcp.is_none() {
6362 if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6363 return replayed;
6364 }
6365 }
6366 let resolved = self.resolve_spawn_spec(&spec)?;
6367 let environment_profile = self.resolve_environment_profile(&resolved)?;
6368 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6369 let context = self.resolve_context(&resolved)?;
6370 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6371 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6372 let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6373 let spawn_workspace_id = match spawn_workspace_id {
6374 Ok(workspace_id) => workspace_id,
6375 Err(error) => {
6376 let result = Err(error);
6377 if harness_mcp.is_none() {
6378 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6379 }
6380 return result;
6381 }
6382 };
6383 if self.managed_worktrees.lock()
6384 .unwrap_or_else(|poisoned| poisoned.into_inner())
6385 .lease_for_workspace(&spawn_workspace_id)
6386 .is_some()
6387 {
6388 let result = Err(failure(
6389 NodeFailureCode::ManagedWorktreeOwnershipConflict,
6390 "managed worktree targets may only be spawned through their lease request",
6391 ));
6392 if harness_mcp.is_none() {
6393 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6394 }
6395 return result;
6396 }
6397 let result = self
6398 .spawn_session_with_deadline(
6399 spawn_workspace_id,
6400 resolved.provider.clone(),
6401 resolved.mode,
6402 resolved.terminal_size,
6403 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6404 environment_profile.clone(),
6405 bundle.clone(),
6406 context.clone(),
6407 None,
6408 None,
6409 SpawnRecordPolicy::Always,
6410 Some(deadline),
6411 &required_capabilities,
6412 harness_mcp,
6413 resolved.approval_level,
6414 resolved.network_allowlist.clone(),
6415 resolved.browser_profile_id.clone(),
6416 )
6417 .await
6418 .map(|(session, _runtime_policy)| {
6419 resolved.receipt_with_materialization(
6420 self.incarnation_id,
6421 session,
6422 environment_profile,
6423 bundle,
6424 context,
6425 )
6426 });
6427 if harness_mcp.is_none() {
6428 self.remember_spawn_spec(spec, result.clone(), accepted_at);
6429 }
6430 result
6431 }
6432
6433 async fn spawn_from_spec_with_harness_mcp(
6434 &self,
6435 reservation_id: HarnessMcpReservationId,
6436 activation_digest: HarnessMcpActivationDigest,
6437 spec: SpawnSpec,
6438 deadline_unix_ms: u64,
6439 ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6440 let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6441 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6442 })?;
6443 if let Some(receipt) = registry
6444 .replay_spawn(&reservation_id, &activation_digest, &spec)
6445 .map_err(harness_mcp_failure)?
6446 {
6447 return Ok(receipt);
6448 }
6449 let resolved = self.resolve_spawn_spec(&spec)?;
6450 let prepared = registry
6451 .prepare_spawn(
6452 &reservation_id,
6453 &activation_digest,
6454 &spec,
6455 deadline_unix_ms,
6456 resolved.provider.clone(),
6457 )
6458 .map_err(harness_mcp_failure)?;
6459 self.claim_harness_mcp_spawn(
6460 &reservation_id,
6461 &activation_digest,
6462 &spec,
6463 Instant::now(),
6464 )?;
6465 let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6466 let mut receipt = match spawn {
6467 Ok(receipt) => receipt,
6468 Err(error) => {
6469 let _ = registry.abort(&reservation_id, &activation_digest);
6470 return Err(error);
6471 }
6472 };
6473 let record_id = self
6474 .session_bindings
6475 .lock()
6476 .unwrap_or_else(|poisoned| poisoned.into_inner())
6477 .get(&receipt.session.session.instance_id)
6478 .and_then(|binding| binding.record_id.clone());
6479 let record_id = match record_id {
6480 Some(record_id) => record_id,
6481 None => {
6482 let rollback = self.remove_session(&receipt.session).await;
6483 let _ = registry.abort(&reservation_id, &activation_digest);
6484 if let Err(rollback_error) = rollback {
6485 return Err(rollback_error);
6486 }
6487 return Err(failure(
6488 NodeFailureCode::BindingMismatch,
6489 "harness MCP spawn did not retain its exact managed record",
6490 ));
6491 }
6492 };
6493 receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6494 reservation_id: reservation_id.clone(),
6495 activation_digest: activation_digest.clone(),
6496 });
6497 if let Err(error) = registry.mark_spawned(
6498 &prepared,
6499 receipt.session.clone(),
6500 record_id,
6501 receipt.clone(),
6502 ) {
6503 let rollback = self.remove_session(&receipt.session).await;
6504 let _ = registry.abort(&reservation_id, &activation_digest);
6505 if let Err(rollback_error) = rollback {
6506 return Err(rollback_error);
6507 }
6508 return Err(harness_mcp_failure(error));
6509 }
6510 Ok(receipt)
6511 }
6512
6513 async fn spawn_managed_worktree(
6514 &self,
6515 request: ManagedWorktreeSpawnRequest,
6516 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6517 self.spawn_managed_worktree_inner(request, None).await
6518 }
6519
6520 async fn spawn_managed_worktree_inner(
6521 &self,
6522 request: ManagedWorktreeSpawnRequest,
6523 durable_v2_key: Option<&SpawnIdempotencyKey>,
6524 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6525 let accepted_at = Instant::now();
6526 if durable_v2_key.is_none() {
6527 if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6528 return replayed;
6529 }
6530 }
6531 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6532 if request.spawn_spec.target.worktree_id.is_some() {
6533 let result = Err(failure(
6534 NodeFailureCode::InvalidRequest,
6535 "managed worktree spawn must not select a caller-provided worktree",
6536 ));
6537 self.remember_managed_spawn_attempt(
6538 durable_v2_key,
6539 request,
6540 result.clone(),
6541 accepted_at,
6542 );
6543 return result;
6544 }
6545 let environment_profile = self.resolve_environment_profile(&resolved)?;
6546 let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6547 let context = self.resolve_context(&resolved)?;
6548 let source_workspace_id = resolved.target.workspace_id.clone();
6549 let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6550 let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6551 let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6552 let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6553 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6554 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6555 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6556 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6557 };
6558 let runtime_policy = timeout(
6559 spawn_deadline_remaining(deadline)?,
6560 self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6561 ).await.map_err(|_| failure(
6562 NodeFailureCode::SpawnDeadlineExceeded,
6563 "provider admission exceeded the managed spawn deadline",
6564 ))??;
6565 if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6566 return Err(failure(
6567 NodeFailureCode::UnsupportedSpawnCapability,
6568 "provider runtime does not admit a required spawn capability",
6569 ));
6570 }
6571 self.ensure_binding_capacity()?;
6572 let source_root = self.workspace_root(&source_workspace_id)?;
6573 profile.validate_for_workspace(&source_root)
6574 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6575 let base_timeout_ms = u64::try_from(
6576 spawn_deadline_remaining(deadline)?.as_millis(),
6577 ).unwrap_or(u64::MAX).max(1);
6578 let base_commit = resolve_base_commit_with_timeout(
6579 &source_root,
6580 profile.base(),
6581 base_timeout_ms,
6582 ).await.map_err(|error| {
6583 if Instant::now() >= deadline || error.message.contains("timed out") {
6584 failure(
6585 NodeFailureCode::SpawnDeadlineExceeded,
6586 "managed worktree base resolution exceeded the spawn deadline",
6587 )
6588 } else {
6589 managed_git_worktree_failure(error)
6590 }
6591 })?;
6592
6593 let lease = {
6594 let _transaction = self.state_transaction.lock()
6595 .unwrap_or_else(|poisoned| poisoned.into_inner());
6596 let previous = self.managed_worktrees.lock()
6597 .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6598 let lease = self.managed_worktrees.lock()
6599 .unwrap_or_else(|poisoned| poisoned.into_inner())
6600 .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6601 .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6602 let previous_replay = match durable_v2_key
6603 .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6604 .transpose()
6605 {
6606 Ok(previous) => previous,
6607 Err(error) => {
6608 *self.managed_worktrees.lock()
6609 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6610 return Err(error);
6611 }
6612 };
6613 if let Err(error) = self.persist_state_locked() {
6614 *self.managed_worktrees.lock()
6615 .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6616 if let (Some(key), Some(previous_replay)) =
6617 (durable_v2_key, previous_replay)
6618 {
6619 self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6620 }
6621 return Err(persistence_failure(error));
6622 }
6623 lease
6624 };
6625 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6626 if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6627 self.managed_worktrees.lock()
6628 .unwrap_or_else(|poisoned| poisoned.into_inner())
6629 .remove_unmutated(&lease.lease_id);
6630 if self.persist_state().is_err() {
6631 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6632 }
6633 let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6634 self.remember_managed_spawn_attempt(
6635 durable_v2_key,
6636 request,
6637 result.clone(),
6638 accepted_at,
6639 );
6640 return result;
6641 }
6642 let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6643 Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6644 Err(error) => {
6645 self.reconcile_failed_allocation(&lease, &source_root).await;
6646 let result = Err(error);
6647 self.remember_managed_spawn_attempt(
6648 durable_v2_key,
6649 request,
6650 result.clone(),
6651 accepted_at,
6652 );
6653 return result;
6654 }
6655 };
6656 let created = match create_git_worktree_with_timeout(
6657 &source_root,
6658 &lease.target_root,
6659 &lease.branch,
6660 Some(&base_commit),
6661 mutation_timeout_ms,
6662 ).await {
6663 Ok(created) => created,
6664 Err(error) => {
6665 self.reconcile_failed_allocation(&lease, &source_root).await;
6666 let result = Err(if Instant::now() >= deadline
6667 || error.message.contains("timed out")
6668 || error.message.contains("deadline")
6669 {
6670 failure(
6671 NodeFailureCode::SpawnDeadlineExceeded,
6672 "managed worktree creation exceeded the spawn deadline",
6673 )
6674 } else {
6675 managed_git_worktree_failure(error)
6676 });
6677 self.remember_managed_spawn_attempt(
6678 durable_v2_key,
6679 request,
6680 result.clone(),
6681 accepted_at,
6682 );
6683 return result;
6684 }
6685 };
6686 if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6687 self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6688 let result = Err(failure(
6689 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6690 "created worktree identity did not match its durable lease",
6691 ));
6692 self.remember_managed_spawn_attempt(
6693 durable_v2_key,
6694 request,
6695 result.clone(),
6696 accepted_at,
6697 );
6698 return result;
6699 }
6700 {
6701 let mut registry = self.managed_worktrees.lock()
6702 .unwrap_or_else(|poisoned| poisoned.into_inner());
6703 let current = registry.get_mut(&lease.lease_id)
6704 .expect("allocated managed worktree lease remains present");
6705 current.expected_head = Some(created.head.clone());
6706 current.state = ManagedWorktreeLeaseState::Ready;
6707 current.updated_at_unix_ms = unix_time_ms();
6708 }
6709 if let Err(error) = self.persist_state() {
6710 self.mark_managed_recovery_required(
6711 &lease.lease_id,
6712 ManagedWorktreeCleanupFailure::Backend,
6713 );
6714 let result = Err(persistence_failure(error));
6715 self.remember_managed_spawn_attempt(
6716 durable_v2_key,
6717 request,
6718 result.clone(),
6719 accepted_at,
6720 );
6721 return result;
6722 }
6723 let ready = self.managed_worktrees.lock()
6724 .unwrap_or_else(|poisoned| poisoned.into_inner())
6725 .get(&lease.lease_id)
6726 .expect("ready managed worktree lease remains present")
6727 .snapshot();
6728 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
6729 if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
6730 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
6731 let result = Err(error);
6732 self.remember_managed_spawn_attempt(
6733 durable_v2_key,
6734 request,
6735 result.clone(),
6736 accepted_at,
6737 );
6738 return result;
6739 }
6740 let spawn = self.spawn_session_with_deadline(
6741 lease.workspace_id.clone(),
6742 resolved.provider.clone(),
6743 resolved.mode,
6744 resolved.terminal_size,
6745 resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6746 environment_profile.clone(),
6747 bundle.clone(),
6748 context.clone(),
6749 Some(lease.lease_id.clone()),
6750 Some(runtime_policy),
6751 SpawnRecordPolicy::Always,
6752 Some(deadline),
6753 &required_capabilities,
6754 None,
6755 resolved.approval_level,
6756 resolved.network_allowlist.clone(),
6757 resolved.browser_profile_id.clone(),
6758 ).await;
6759 let (session, effective_runtime_policy) = match spawn {
6760 Ok(spawned) => spawned,
6761 Err(error) => {
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(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 };
6782 let (record_id, raw_inventory_record_id) = {
6783 let mut bindings = self.session_bindings.lock()
6784 .unwrap_or_else(|poisoned| poisoned.into_inner());
6785 let binding = bindings.get_mut(&session.session.instance_id)
6786 .expect("successful spawn retains its session binding");
6787 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
6788 managed_spawn_record_ownership(binding, effective_runtime_policy)
6789 };
6790 let bound = self.managed_worktrees.lock()
6791 .unwrap_or_else(|poisoned| poisoned.into_inner())
6792 .bind_session(
6793 &lease.lease_id,
6794 ManagedWorktreeSessionHolder {
6795 incarnation_id: self.incarnation_id,
6796 instance_id: session.session.instance_id,
6797 generation: session.session.generation,
6798 },
6799 record_id,
6800 unix_time_ms(),
6801 )
6802 .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
6803 let snapshot = match bound {
6804 Ok(snapshot) => snapshot,
6805 Err(error) => {
6806 if raw_inventory_record_id
6807 .as_ref()
6808 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6809 {
6810 self.mark_managed_recovery_required(
6811 &lease.lease_id,
6812 ManagedWorktreeCleanupFailure::Backend,
6813 );
6814 }
6815 let _ = self.remove_session(&session).await;
6816 self.mark_managed_recovery_required(
6817 &lease.lease_id,
6818 ManagedWorktreeCleanupFailure::Backend,
6819 );
6820 let result = Err(error);
6821 self.remember_managed_spawn_attempt(
6822 durable_v2_key,
6823 request,
6824 result.clone(),
6825 accepted_at,
6826 );
6827 return result;
6828 }
6829 };
6830 if let Err(error) = self.persist_state() {
6831 if raw_inventory_record_id
6832 .as_ref()
6833 .is_some_and(|record_id| self.discard_record(record_id).is_err())
6834 {
6835 self.mark_managed_recovery_required(
6836 &lease.lease_id,
6837 ManagedWorktreeCleanupFailure::Backend,
6838 );
6839 }
6840 let _ = self.remove_session(&session).await;
6841 if self
6842 .cleanup_managed_worktree(&lease.lease_id, true)
6843 .await
6844 .is_err()
6845 {
6846 self.mark_managed_recovery_required(
6847 &lease.lease_id,
6848 ManagedWorktreeCleanupFailure::Backend,
6849 );
6850 }
6851 let result = Err(persistence_failure(error));
6852 self.remember_managed_spawn_attempt(
6853 durable_v2_key,
6854 request,
6855 result.clone(),
6856 accepted_at,
6857 );
6858 return result;
6859 }
6860 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
6861 let receipt = managed_spawn_receipt(
6862 &resolved,
6863 self.incarnation_id,
6864 session,
6865 snapshot,
6866 environment_profile,
6867 bundle,
6868 context,
6869 );
6870 let result = Ok(receipt);
6871 self.remember_managed_spawn_attempt(
6872 durable_v2_key,
6873 request,
6874 result.clone(),
6875 accepted_at,
6876 );
6877 result
6878 }
6879
6880 async fn spawn_managed_worktree_v2(
6881 &self,
6882 request: ManagedWorktreeSpawnRequestV2,
6883 ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6884 if request.spawn_spec.target.node_id != self.node_id {
6885 return Err(failure(
6886 NodeFailureCode::SpawnTargetMismatch,
6887 "spawn target does not match this node",
6888 ));
6889 }
6890 if request.spawn_spec.target.worktree_id.is_some() {
6891 return Err(failure(
6892 NodeFailureCode::InvalidRequest,
6893 "managed worktree spawn must not select a caller-provided worktree",
6894 ));
6895 }
6896 let request_digest = managed_spawn_request_digest_v2(&request)?;
6897 let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
6898 match replay {
6899 DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
6900 DurableManagedSpawnReplayDecision::PendingLinked => {
6901 return Err(failure(
6902 NodeFailureCode::ManagedWorktreeRecoveryRequired,
6903 "managed worktree V2 reservation has an incomplete durable allocation",
6904 ));
6905 }
6906 DurableManagedSpawnReplayDecision::Reserved => {}
6907 }
6908 let validated = (|| {
6909 let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6910 let profile = self.managed_profile(
6911 &resolved.target.workspace_id,
6912 &request.worktree_profile_id,
6913 )?;
6914 if profile.revision() != &request.expected_profile_revision {
6915 return Err(failure(
6916 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
6917 "managed worktree profile revision does not match the loaded profile",
6918 ));
6919 }
6920 Ok(())
6921 })();
6922 match validated {
6923 Ok(()) => {}
6924 Err(error) => {
6925 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6926 return Err(error);
6927 }
6928 }
6929 let legacy = ManagedWorktreeSpawnRequest {
6930 spawn_spec: request.spawn_spec.clone(),
6931 worktree_profile_id: request.worktree_profile_id.clone(),
6932 };
6933 match self
6934 .spawn_managed_worktree_inner(
6935 legacy,
6936 Some(&request.spawn_spec.idempotency_key),
6937 )
6938 .await
6939 {
6940 Ok(receipt) => {
6941 self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
6942 Ok(receipt)
6943 }
6944 Err(error) => {
6945 self.remove_unallocated_durable_managed_spawn_v2(&request)?;
6946 Err(error)
6947 }
6948 }
6949 }
6950
6951 fn managed_profile(
6952 &self,
6953 workspace_id: &WorkspaceId,
6954 profile_id: &WorktreeProfileId,
6955 ) -> Result<ManagedWorktreeProfile, NodeFailure> {
6956 if self.worktree_service_modes.get(workspace_id).copied()
6957 != Some(WorktreeServiceMode::Managed)
6958 {
6959 return Err(failure(
6960 NodeFailureCode::UnsupportedSpawnCapability,
6961 "workspace does not enable managed worktrees",
6962 ));
6963 }
6964 self.managed_worktree_profiles.get(workspace_id)
6965 .and_then(|profiles| profiles.get(profile_id))
6966 .cloned()
6967 .ok_or_else(|| failure(
6968 NodeFailureCode::InvalidRequest,
6969 "managed worktree profile is unavailable for this workspace",
6970 ))
6971 }
6972
6973 async fn reconcile_failed_allocation(
6974 &self,
6975 lease: &ManagedWorktreeLeaseRecord,
6976 source_root: &str,
6977 ) {
6978 match list_git_worktrees(source_root).await {
6979 Ok(worktrees) => {
6980 match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
6981 None => {
6982 match std::fs::symlink_metadata(&lease.target_root) {
6983 Err(error) if error.kind() == io::ErrorKind::NotFound => {
6984 self.managed_worktrees.lock()
6985 .unwrap_or_else(|poisoned| poisoned.into_inner())
6986 .remove_unmutated(&lease.lease_id);
6987 let _ = self.persist_state();
6988 }
6989 _ => self.mark_managed_recovery_required(
6990 &lease.lease_id,
6991 ManagedWorktreeCleanupFailure::OwnershipConflict,
6992 ),
6993 }
6994 }
6995 Some(item) if exact_owned_worktree(
6996 lease,
6997 &item.path,
6998 item.branch.as_deref(),
6999 &item.head,
7000 ) => {
7001 let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7002 }
7003 Some(_) => self.mark_managed_recovery_required(
7004 &lease.lease_id,
7005 ManagedWorktreeCleanupFailure::OwnershipConflict,
7006 ),
7007 }
7008 }
7009 Err(_) => self.mark_managed_recovery_required(
7010 &lease.lease_id,
7011 ManagedWorktreeCleanupFailure::Backend,
7012 ),
7013 }
7014 }
7015
7016 async fn reconcile_managed_worktrees(&self) {
7017 let leases = self.managed_worktrees.lock()
7018 .unwrap_or_else(|poisoned| poisoned.into_inner())
7019 .active_records()
7020 .cloned()
7021 .collect::<Vec<_>>();
7022 for lease in leases {
7023 let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7024 Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7025 _ => {
7026 self.mark_managed_recovery_required(
7027 &lease.lease_id,
7028 ManagedWorktreeCleanupFailure::OwnershipConflict,
7029 );
7030 continue;
7031 }
7032 };
7033 if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7034 self.mark_managed_recovery_required(
7035 &lease.lease_id,
7036 ManagedWorktreeCleanupFailure::OwnershipConflict,
7037 );
7038 continue;
7039 }
7040 let source_root = match self.workspace_root(&lease.source_workspace_id) {
7041 Ok(root) => root,
7042 Err(_) => {
7043 self.mark_managed_recovery_required(
7044 &lease.lease_id,
7045 ManagedWorktreeCleanupFailure::OwnershipConflict,
7046 );
7047 continue;
7048 }
7049 };
7050 let worktrees = match list_git_worktrees(&source_root).await {
7051 Ok(worktrees) => worktrees,
7052 Err(_) => {
7053 self.mark_managed_recovery_required(
7054 &lease.lease_id,
7055 ManagedWorktreeCleanupFailure::Backend,
7056 );
7057 continue;
7058 }
7059 };
7060 let listed = worktrees.iter()
7061 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7062 let Some(listed) = listed else {
7063 match std::fs::symlink_metadata(&lease.target_root) {
7064 Err(error) if error.kind() == io::ErrorKind::NotFound
7065 && lease.state == ManagedWorktreeLeaseState::Allocating
7066 && !lease.has_holders() => {
7067 let removed = self.managed_worktrees.lock()
7068 .unwrap_or_else(|poisoned| poisoned.into_inner())
7069 .tombstone(&lease.lease_id, unix_time_ms());
7070 if self.persist_state().is_ok() && removed.is_some() {
7071 self.publish(NodeEvent::ManagedWorktreeRemoved {
7072 lease_id: lease.lease_id.clone(),
7073 });
7074 }
7075 }
7076 _ => self.mark_managed_recovery_required(
7077 &lease.lease_id,
7078 ManagedWorktreeCleanupFailure::OwnershipConflict,
7079 ),
7080 }
7081 continue;
7082 };
7083 if listed.is_main || listed.is_bare || !exact_owned_worktree(
7084 &lease,
7085 &listed.path,
7086 listed.branch.as_deref(),
7087 &listed.head,
7088 ) {
7089 self.mark_managed_recovery_required(
7090 &lease.lease_id,
7091 ManagedWorktreeCleanupFailure::OwnershipConflict,
7092 );
7093 continue;
7094 }
7095 if !self.workspaces.read()
7096 .unwrap_or_else(|poisoned| poisoned.into_inner())
7097 .contains_key(&lease.workspace_id)
7098 {
7099 if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7100 self.mark_managed_recovery_required(
7101 &lease.lease_id,
7102 ManagedWorktreeCleanupFailure::Backend,
7103 );
7104 continue;
7105 }
7106 }
7107 let snapshot = {
7108 let mut registry = self.managed_worktrees.lock()
7109 .unwrap_or_else(|poisoned| poisoned.into_inner());
7110 let current = registry.get_mut(&lease.lease_id)
7111 .expect("reconciled managed lease remains present");
7112 current.expected_head = Some(listed.head.clone());
7113 current.cleanup_failure = None;
7114 current.state = if current.has_holders() {
7115 ManagedWorktreeLeaseState::InUse
7116 } else if current.retention == ManagedWorktreeRetention::Retain {
7117 ManagedWorktreeLeaseState::Retained
7118 } else {
7119 ManagedWorktreeLeaseState::Ready
7120 };
7121 current.updated_at_unix_ms = unix_time_ms();
7122 current.snapshot()
7123 };
7124 if self.persist_state().is_err() {
7125 self.mark_managed_recovery_required(
7126 &lease.lease_id,
7127 ManagedWorktreeCleanupFailure::Backend,
7128 );
7129 continue;
7130 }
7131 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7132 if snapshot.state == ManagedWorktreeLeaseState::Ready {
7133 let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7134 }
7135 }
7136 }
7137
7138 async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7158 let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7159 .session_records
7160 .lock()
7161 .unwrap_or_else(|poisoned| poisoned.into_inner())
7162 .records
7163 .values()
7164 .filter(|record| {
7165 record.state == ManagedSessionState::Live
7166 && record.exported_context.is_none()
7167 && record.provider_session.is_some()
7168 })
7169 .filter_map(|record| {
7170 record
7171 .active_session
7172 .clone()
7173 .map(|session| (record.record_id.clone(), session))
7174 })
7175 .collect();
7176 for (record_id, session) in candidates {
7177 let shared = Arc::clone(self);
7178 tokio::spawn(async move {
7179 shared
7180 .reconcile_context_pack_export_for_record(&record_id, session)
7181 .await;
7182 });
7183 }
7184 }
7185
7186 async fn reconcile_context_pack_export_for_record(
7205 &self,
7206 record_id: &SessionRecordId,
7207 session: SessionAddress,
7208 ) {
7209 let Ok(record) = self.record(record_id) else {
7210 return;
7211 };
7212 if !matches!(
7213 record.state,
7214 ManagedSessionState::Live | ManagedSessionState::Dormant
7215 ) || record.exported_context.is_some()
7216 || record.provider_session.is_none()
7217 {
7218 return;
7219 }
7220 let Ok(receipt) = self
7227 .export_context_pack_for_session_record(&record.record_id, &session, true)
7228 .await
7229 else {
7230 return;
7231 };
7232 self.set_exported_context(&record.record_id, receipt);
7233 }
7234
7235 fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7240 let transaction = self
7241 .state_transaction
7242 .lock()
7243 .unwrap_or_else(|poisoned| poisoned.into_inner());
7244 let (previous, updated) = {
7245 let mut records = self
7246 .session_records
7247 .lock()
7248 .unwrap_or_else(|poisoned| poisoned.into_inner());
7249 let Some(record) = records.records.get_mut(record_id) else {
7250 return;
7251 };
7252 if record.exported_context.is_some() {
7253 return;
7254 }
7255 let previous = record.clone();
7256 record.exported_context = Some(receipt);
7257 record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7258 (previous, record.clone())
7259 };
7260 if self.persist_state_locked().is_err() {
7261 self.session_records
7262 .lock()
7263 .unwrap_or_else(|poisoned| poisoned.into_inner())
7264 .records
7265 .insert(record_id.clone(), previous);
7266 return;
7267 }
7268 drop(transaction);
7269 self.publish_record(updated);
7270 }
7271
7272 fn managed_record_export_target_for_instance(
7282 &self,
7283 instance_id: AgentInstanceId,
7284 generation: SessionGeneration,
7285 ) -> Option<(SessionRecordId, SessionAddress)> {
7286 let bindings = self
7287 .session_bindings
7288 .lock()
7289 .unwrap_or_else(|poisoned| poisoned.into_inner());
7290 let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7291 let record_id = binding.record_id.clone()?;
7292 Some((
7293 record_id,
7294 SessionAddress {
7295 workspace_id: binding.workspace_id.clone(),
7296 session: SessionKey { instance_id, generation },
7297 },
7298 ))
7299 }
7300
7301 fn mark_managed_recovery_required(
7302 &self,
7303 lease_id: &ManagedWorktreeLeaseId,
7304 failure_kind: ManagedWorktreeCleanupFailure,
7305 ) {
7306 let snapshot = {
7307 let mut registry = self.managed_worktrees.lock()
7308 .unwrap_or_else(|poisoned| poisoned.into_inner());
7309 let Some(lease) = registry.get_mut(lease_id) else { return };
7310 lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7311 lease.cleanup_failure = Some(failure_kind);
7312 lease.updated_at_unix_ms = unix_time_ms();
7313 lease.snapshot()
7314 };
7315 let _ = self.persist_state();
7316 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7317 }
7318
7319 fn set_persistence_error(&self, error: Option<String>) {
7320 *self
7321 .persistence_error
7322 .write()
7323 .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7324 .map(|message| session_registry::sanitized_persistence_summary(&message));
7325 }
7326
7327 fn persist_state(&self) -> io::Result<()> {
7328 let _transaction = self
7329 .state_transaction
7330 .lock()
7331 .unwrap_or_else(|poisoned| poisoned.into_inner());
7332 self.persist_state_locked()
7333 }
7334
7335 fn persist_state_locked(&self) -> io::Result<()> {
7336 let workspaces = self
7337 .workspaces
7338 .read()
7339 .unwrap_or_else(|poisoned| poisoned.into_inner())
7340 .clone();
7341 let records = self
7342 .session_records
7343 .lock()
7344 .unwrap_or_else(|poisoned| poisoned.into_inner())
7345 .records
7346 .values()
7347 .cloned()
7348 .collect::<Vec<_>>();
7349 let mut managed = self.managed_worktrees
7350 .lock()
7351 .unwrap_or_else(|poisoned| poisoned.into_inner());
7352 managed.reattach_record_holders(
7353 &managed_worktree_record_holders(&records),
7354 unix_time_ms(),
7355 );
7356 let materializations = self
7357 .materializations
7358 .lock()
7359 .unwrap_or_else(|poisoned| poisoned.into_inner())
7360 .values()
7361 .cloned()
7362 .collect::<Vec<_>>();
7363 let managed_spawn_replays = self.managed_spawn_replays
7364 .lock()
7365 .unwrap_or_else(|poisoned| poisoned.into_inner())
7366 .values()
7367 .cloned()
7368 .collect::<Vec<_>>();
7369 let result = session_registry::save_v10(
7370 self.state_path.as_deref(),
7371 &self.node_id,
7372 &workspaces,
7373 &records,
7374 &managed.records(),
7375 &managed.tombstones(),
7376 &materializations,
7377 &managed_spawn_replays,
7378 );
7379 match &result {
7380 Ok(warning) => self.set_persistence_error(warning.clone()),
7381 Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7382 }
7383 result.map(|_| ())
7384 }
7385
7386 async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7387 let _mutation_guard = self.mutation_gate.lock().await;
7388 self.begin_shutdown_locked().await
7389 }
7390
7391 async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7392 if self.shutdown.swap(true, Ordering::AcqRel) {
7393 return Ok(());
7394 }
7395 SessionLifecycleHook::on_shutdown(self);
7396 let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7397 let mut first_error = None;
7398 for session in &self.handle.snapshot().sessions {
7399 let remaining = deadline.saturating_duration_since(Instant::now());
7400 if remaining.is_zero() {
7401 first_error.get_or_insert_with(|| {
7402 failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7403 });
7404 break;
7405 }
7406 if let Err(error) = self
7407 .dispatch_bounded(
7408 ControlCommand::Stop {
7409 instance_id: session.instance_id,
7410 force: true,
7411 },
7412 remaining,
7413 )
7414 .await
7415 {
7416 first_error.get_or_insert(error);
7417 }
7418 }
7419 self.shutdown_notify.notify_waiters();
7420 match first_error {
7421 Some(error) => Err(error),
7422 None => Ok(()),
7423 }
7424 }
7425
7426 fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7427 let bindings = self
7428 .session_bindings
7429 .lock()
7430 .unwrap_or_else(|poisoned| poisoned.into_inner());
7431 if bindings.len() >= CONTROL_SESSIONS_MAX {
7432 Err(failure(
7433 NodeFailureCode::BackendBusy,
7434 "node session-binding capacity is full; remove an existing session first",
7435 ))
7436 } else {
7437 Ok(())
7438 }
7439 }
7440
7441 fn ensure_workspace_environment_profile_compatible(
7448 &self,
7449 workspace_id: &WorkspaceId,
7450 environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7451 ) -> Result<(), NodeFailure> {
7452 let bindings = self
7453 .session_bindings
7454 .lock()
7455 .unwrap_or_else(|poisoned| poisoned.into_inner());
7456 for binding in bindings.values() {
7457 if &binding.workspace_id != workspace_id {
7458 continue;
7459 }
7460 if binding.environment_profile.as_ref() != environment_profile {
7461 return Err(failure(
7462 NodeFailureCode::WorkspaceBusy,
7463 "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7464 ));
7465 }
7466 }
7467 Ok(())
7468 }
7469
7470 #[cfg(test)]
7471 fn bind_session(
7472 &self,
7473 instance_id: AgentInstanceId,
7474 workspace_id: WorkspaceId,
7475 generation: SessionGeneration,
7476 ) {
7477 self.bind_session_with_policy(
7478 &SessionAddress {
7479 workspace_id,
7480 session: SessionKey { instance_id, generation },
7481 },
7482 ProviderRuntimePolicy::raw_pty(),
7483 None,
7484 );
7485 }
7486
7487 #[cfg(test)]
7488 fn bind_session_with_policy(
7489 &self,
7490 address: &SessionAddress,
7491 runtime_policy: ProviderRuntimePolicy,
7492 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7493 ) {
7494 self.bind_session_with_materialization(
7495 address,
7496 runtime_policy,
7497 environment_profile,
7498 None,
7499 None,
7500 None,
7501 );
7502 }
7503
7504 fn bind_session_with_materialization(
7505 &self,
7506 address: &SessionAddress,
7507 runtime_policy: ProviderRuntimePolicy,
7508 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7509 bundle: Option<ResolvedBundleReceipt>,
7510 context: Option<ResolvedContextPackReceipt>,
7511 materialization_id: Option<MaterializationId>,
7512 ) {
7513 let mut bindings = self
7514 .session_bindings
7515 .lock()
7516 .unwrap_or_else(|poisoned| poisoned.into_inner());
7517 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7518 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7519 bindings.insert(address.session.instance_id, SessionBinding {
7520 workspace_id: address.workspace_id.clone(),
7521 generation: address.session.generation,
7522 runtime_policy,
7523 pending_resume: None,
7524 record_id: None,
7525 managed_worktree_lease_id: None,
7526 environment_profile,
7527 bundle,
7528 context,
7529 materialization_id,
7530 });
7531 }
7532
7533 #[cfg(test)]
7534 fn bind_managed_session(
7535 &self,
7536 address: &SessionAddress,
7537 record_id: SessionRecordId,
7538 runtime_policy: ProviderRuntimePolicy,
7539 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7540 ) {
7541 self.bind_managed_session_with_materialization(
7542 address,
7543 record_id,
7544 runtime_policy,
7545 environment_profile,
7546 None,
7547 None,
7548 None,
7549 );
7550 }
7551
7552 fn bind_managed_session_with_materialization(
7553 &self,
7554 address: &SessionAddress,
7555 record_id: SessionRecordId,
7556 runtime_policy: ProviderRuntimePolicy,
7557 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7558 bundle: Option<ResolvedBundleReceipt>,
7559 context: Option<ResolvedContextPackReceipt>,
7560 materialization_id: Option<MaterializationId>,
7561 ) {
7562 let mut bindings = self
7563 .session_bindings
7564 .lock()
7565 .unwrap_or_else(|poisoned| poisoned.into_inner());
7566 debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7567 debug_assert!(!bindings.contains_key(&address.session.instance_id));
7568 bindings.insert(
7569 address.session.instance_id,
7570 SessionBinding {
7571 workspace_id: address.workspace_id.clone(),
7572 generation: address.session.generation,
7573 runtime_policy,
7574 pending_resume: None,
7575 record_id: Some(record_id),
7576 managed_worktree_lease_id: None,
7577 environment_profile,
7578 bundle,
7579 context,
7580 materialization_id,
7581 },
7582 );
7583 }
7584
7585 #[cfg(test)]
7586 fn bind_spawn_session(
7587 &self,
7588 address: &SessionAddress,
7589 provider: AgentId,
7590 mode: SessionMode,
7591 runtime_policy: ProviderRuntimePolicy,
7592 record_policy: SpawnRecordPolicy,
7593 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7594 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7595 self.bind_spawn_session_with_materialization(
7596 address,
7597 provider,
7598 mode,
7599 runtime_policy,
7600 record_policy,
7601 environment_profile,
7602 None,
7603 None,
7604 None,
7605 )
7606 }
7607
7608 fn bind_spawn_session_with_materialization(
7609 &self,
7610 address: &SessionAddress,
7611 provider: AgentId,
7612 mode: SessionMode,
7613 runtime_policy: ProviderRuntimePolicy,
7614 record_policy: SpawnRecordPolicy,
7615 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7616 bundle: Option<ResolvedBundleReceipt>,
7617 context: Option<ResolvedContextPackReceipt>,
7618 materialization_id: Option<MaterializationId>,
7619 ) -> Result<Option<SessionRecordId>, NodeFailure> {
7620 if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7621 && !runtime_policy.provider_session_identity
7622 {
7623 self.bind_session_with_materialization(
7624 address,
7625 runtime_policy,
7626 environment_profile,
7627 bundle,
7628 context,
7629 materialization_id,
7630 );
7631 return Ok(None);
7632 }
7633 let record_owns_lifecycle = runtime_policy.provider_session_identity;
7634 let record = self.new_record(
7635 address,
7636 provider,
7637 mode,
7638 active_record_state(runtime_policy.provider_session_identity, false),
7639 None,
7640 record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7641 record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7642 record_owns_lifecycle.then(|| context.clone()).flatten(),
7643 )?;
7644 let record_id = record.record_id.clone();
7645 let transaction = self
7646 .state_transaction
7647 .lock()
7648 .unwrap_or_else(|poisoned| poisoned.into_inner());
7649 self.insert_record(record.clone())?;
7650 let previous_materialization = if record_owns_lifecycle {
7651 if let Some(materialization_id) = materialization_id.as_ref() {
7652 let mut materializations = self
7653 .materializations
7654 .lock()
7655 .unwrap_or_else(|poisoned| poisoned.into_inner());
7656 let Some(ownership) = materializations.get_mut(materialization_id) else {
7657 drop(materializations);
7658 self.remove_record_memory(&record_id);
7659 return Err(failure(
7660 NodeFailureCode::BackendOperationFailed,
7661 "session environment ownership disappeared before binding",
7662 ));
7663 };
7664 let previous = ownership.clone();
7665 if ownership
7666 .transfer_to_record(record_id.clone(), unix_time_ms())
7667 .is_err()
7668 {
7669 drop(materializations);
7670 self.remove_record_memory(&record_id);
7671 return Err(failure(
7672 NodeFailureCode::BackendOperationFailed,
7673 "session environment ownership transfer failed",
7674 ));
7675 }
7676 Some(previous)
7677 } else {
7678 None
7679 }
7680 } else {
7681 None
7682 };
7683 self.bind_managed_session_with_materialization(
7684 address,
7685 record_id.clone(),
7686 runtime_policy,
7687 environment_profile,
7688 bundle,
7689 context,
7690 materialization_id.clone(),
7691 );
7692 if let Err(error) = self.persist_state_locked() {
7693 self.remove_binding(address);
7694 self.remove_record_memory(&record_id);
7695 if let (Some(materialization_id), Some(previous)) =
7696 (materialization_id.as_ref(), previous_materialization)
7697 {
7698 self.materializations
7699 .lock()
7700 .unwrap_or_else(|poisoned| poisoned.into_inner())
7701 .insert(materialization_id.clone(), previous);
7702 }
7703 return Err(persistence_failure(error));
7704 }
7705 drop(transaction);
7706 self.publish_record(record);
7707 Ok(Some(record_id))
7708 }
7709
7710 fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
7711 for _ in 0..8 {
7712 let bytes = random_nonce().map_err(|error| {
7713 failure(
7714 NodeFailureCode::BackendOperationFailed,
7715 &format!("session record identity generation failed: {error}"),
7716 )
7717 })?;
7718 let mut value = String::with_capacity(27);
7719 value.push_str("sr-");
7720 for byte in &bytes[..12] {
7721 use std::fmt::Write as _;
7722 let _ = write!(&mut value, "{byte:02x}");
7723 }
7724 let record_id = SessionRecordId::new(value).map_err(|error| {
7725 failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
7726 })?;
7727 let records = self
7728 .session_records
7729 .lock()
7730 .unwrap_or_else(|poisoned| poisoned.into_inner());
7731 if !records.records.contains_key(&record_id) {
7732 return Ok(record_id);
7733 }
7734 }
7735 Err(failure(
7736 NodeFailureCode::BackendBusy,
7737 "could not allocate a unique session record ID",
7738 ))
7739 }
7740
7741 fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
7742 for _ in 0..8 {
7743 let bytes = random_nonce().map_err(|error| {
7744 failure(
7745 NodeFailureCode::BackendOperationFailed,
7746 &format!("task identity generation failed: {error}"),
7747 )
7748 })?;
7749 let mut nonce = [0_u8; 12];
7750 nonce.copy_from_slice(&bytes[..12]);
7751 let task_id = TaskId::from_nonce(nonce);
7752 let records = self
7753 .session_records
7754 .lock()
7755 .unwrap_or_else(|poisoned| poisoned.into_inner());
7756 if !records.records.values().any(|record| {
7757 record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
7758 == Some(&task_id)
7759 }) {
7760 return Ok(task_id);
7761 }
7762 }
7763 Err(failure(
7764 NodeFailureCode::BackendBusy,
7765 "could not allocate a unique task ID",
7766 ))
7767 }
7768
7769 fn default_record_name(&self, provider: &AgentId) -> String {
7770 let records = self
7771 .session_records
7772 .lock()
7773 .unwrap_or_else(|poisoned| poisoned.into_inner());
7774 let ordinal = records
7775 .records
7776 .values()
7777 .filter(|record| &record.provider == provider)
7778 .count()
7779 .saturating_add(1);
7780 format!("{} #{ordinal}", provider.as_str())
7781 }
7782
7783 fn new_record(
7784 &self,
7785 address: &SessionAddress,
7786 provider: AgentId,
7787 mode: SessionMode,
7788 state: ManagedSessionState,
7789 provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
7790 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7791 bundle: Option<ResolvedBundleReceipt>,
7792 context: Option<ResolvedContextPackReceipt>,
7793 ) -> Result<ManagedSessionRecord, NodeFailure> {
7794 let canonical_root = self.workspace_root(&address.workspace_id)?;
7795 let now = unix_time_ms();
7796 Ok(ManagedSessionRecord {
7797 record_id: self.allocate_record_id()?,
7798 display_name: self.default_record_name(&provider),
7799 provider,
7800 mode,
7801 state,
7802 workspace_id: address.workspace_id.clone(),
7803 canonical_root: opaque_windows_path(canonical_root),
7804 provider_session,
7805 active_session: Some(address.clone()),
7806 environment_profile,
7807 bundle,
7808 context_id: context.as_ref().map(|receipt| receipt.id.clone()),
7809 context,
7810 exported_context: None,
7811 task_binding: None,
7812 created_at_unix_ms: now,
7813 updated_at_unix_ms: now,
7814 last_error: None,
7815 })
7816 }
7817
7818 fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
7819 let mut records = self
7820 .session_records
7821 .lock()
7822 .unwrap_or_else(|poisoned| poisoned.into_inner());
7823 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
7824 return Err(failure(
7825 NodeFailureCode::BackendBusy,
7826 "managed session record capacity is full; forget an inactive record first",
7827 ));
7828 }
7829 if let Some(identity) = record.provider_session.as_ref() {
7830 if records.records.values().any(|candidate| {
7831 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
7832 session_registry::same_provider_session(
7833 &candidate.provider,
7834 candidate_identity,
7835 &record.provider,
7836 identity,
7837 )
7838 })
7839 }) {
7840 return Err(failure(
7841 NodeFailureCode::SessionRecordConflict,
7842 "provider session is already represented by another managed record",
7843 ));
7844 }
7845 }
7846 match records.records.entry(record.record_id.clone()) {
7847 std::collections::btree_map::Entry::Vacant(entry) => {
7848 entry.insert(record);
7849 }
7850 std::collections::btree_map::Entry::Occupied(_) => {
7851 return Err(failure(
7852 NodeFailureCode::SessionRecordConflict,
7853 "session record ID collision",
7854 ));
7855 }
7856 }
7857 Ok(())
7858 }
7859
7860 fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
7861 self.session_records
7862 .lock()
7863 .unwrap_or_else(|poisoned| poisoned.into_inner())
7864 .records
7865 .get(record_id)
7866 .cloned()
7867 .ok_or_else(|| {
7868 failure(
7869 NodeFailureCode::UnknownSessionRecord,
7870 "managed session record does not exist",
7871 )
7872 })
7873 }
7874
7875 fn revalidate_session_record_preview(
7876 &self,
7877 expected: &ManagedSessionRecord,
7878 identity: &ProviderSessionIdentity,
7879 ) -> Result<(), NodeFailure> {
7880 let current = self.record(&expected.record_id)?;
7881 let current_root = self.workspace_root(¤t.workspace_id)?;
7882 if current.provider != expected.provider
7883 || current.workspace_id != expected.workspace_id
7884 || current.canonical_root != expected.canonical_root
7885 || current.provider_session.as_ref() != Some(identity)
7886 || !platform::roots_equal(
7887 ¤t_root,
7888 windows_path_text(¤t.canonical_root),
7889 )
7890 {
7891 return Err(failure(
7892 NodeFailureCode::SessionRecordConflict,
7893 "managed session changed while its preview was loading",
7894 ));
7895 }
7896 Ok(())
7897 }
7898
7899 fn revalidate_session_record_context_export(
7900 &self,
7901 expected: &ManagedSessionRecord,
7902 identity: Option<&ProviderSessionIdentity>,
7903 session: &SessionAddress,
7904 allow_clean_detachment: bool,
7905 ) -> Result<(), NodeFailure> {
7906 let current = self.record(&expected.record_id)?;
7907 let current_root = self.workspace_root(¤t.workspace_id)?;
7908 let snapshot = self.internal_session_snapshot(session)?;
7909 let binding = self
7910 .session_bindings
7911 .lock()
7912 .unwrap_or_else(|poisoned| poisoned.into_inner())
7913 .get(&session.session.instance_id)
7914 .cloned()
7915 .ok_or_else(|| {
7916 failure(
7917 NodeFailureCode::SessionRecordConflict,
7918 "managed session runtime binding disappeared during context export",
7919 )
7920 })?;
7921 if !session_record_context_export_binding_is_exact(
7922 expected,
7923 ¤t,
7924 identity,
7925 session,
7926 &binding,
7927 &snapshot.agent_id,
7928 ¤t_root,
7929 allow_clean_detachment,
7930 ) || !session_record_context_export_source_is_usable(
7931 ¤t,
7932 &snapshot.status,
7933 allow_clean_detachment,
7934 ) {
7935 return Err(failure(
7936 NodeFailureCode::SessionRecordConflict,
7937 "managed session binding changed during context export",
7938 ));
7939 }
7940 Ok(())
7941 }
7942
7943 fn revalidate_loaded_history(
7944 &self,
7945 session: &SessionAddress,
7946 candidate_id: &str,
7947 loaded: &HistorySessionRecord,
7948 ) -> Result<(), NodeFailure> {
7949 let snapshot = self.internal_session_snapshot(session)?;
7950 if snapshot.history.pending.is_some()
7951 || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
7952 || snapshot.history.loaded.as_ref() != Some(loaded)
7953 {
7954 return Err(failure(
7955 NodeFailureCode::SessionRecordConflict,
7956 "managed session history changed during context export",
7957 ));
7958 }
7959 Ok(())
7960 }
7961
7962 fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
7963 self.session_records
7964 .lock()
7965 .unwrap_or_else(|poisoned| poisoned.into_inner())
7966 .records
7967 .remove(record_id)
7968 }
7969
7970 fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
7971 if self.destroy_record_materialization(record_id)? {
7972 self.publish(NodeEvent::SessionRecordRemoved {
7973 record_id: record_id.clone(),
7974 });
7975 return Ok(());
7976 }
7977 let transaction = self
7978 .state_transaction
7979 .lock()
7980 .unwrap_or_else(|poisoned| poisoned.into_inner());
7981 let Some(removed) = self.remove_record_memory(record_id) else {
7982 return Ok(());
7983 };
7984 if let Err(error) = self.persist_state_locked() {
7985 let _ = self.insert_record(removed);
7986 return Err(persistence_failure(error));
7987 }
7988 drop(transaction);
7989 self.publish(NodeEvent::SessionRecordRemoved {
7990 record_id: record_id.clone(),
7991 });
7992 Ok(())
7993 }
7994
7995 fn publish_record(&self, record: ManagedSessionRecord) {
7996 self.publish(NodeEvent::SessionRecordUpserted { record });
7997 }
7998
7999 fn mark_record_error(
8000 &self,
8001 record_id: &SessionRecordId,
8002 message: &str,
8003 ) -> Result<(), NodeFailure> {
8004 let transaction = self
8005 .state_transaction
8006 .lock()
8007 .unwrap_or_else(|poisoned| poisoned.into_inner());
8008 let previous = {
8009 let mut records = self
8010 .session_records
8011 .lock()
8012 .unwrap_or_else(|poisoned| poisoned.into_inner());
8013 let Some(record) = records.records.get_mut(record_id) else {
8014 return Ok(());
8015 };
8016 let previous = record.clone();
8017 record.state = ManagedSessionState::Unavailable;
8018 record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8019 record.updated_at_unix_ms = unix_time_ms();
8020 previous
8021 };
8022 if let Err(error) = self.persist_state_locked() {
8023 self.session_records
8024 .lock()
8025 .unwrap_or_else(|poisoned| poisoned.into_inner())
8026 .records
8027 .insert(record_id.clone(), previous);
8028 return Err(persistence_failure(error));
8029 }
8030 let record = self.record(record_id)?;
8031 drop(transaction);
8032 self.publish_record(record);
8033 Ok(())
8034 }
8035
8036 fn rename_session_record(
8037 &self,
8038 record_id: &SessionRecordId,
8039 display_name: String,
8040 ) -> Result<ManagedSessionRecord, NodeFailure> {
8041 validate_display_name(&display_name)
8042 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8043 let _transaction = self
8044 .state_transaction
8045 .lock()
8046 .unwrap_or_else(|poisoned| poisoned.into_inner());
8047 let previous = {
8048 let mut records = self
8049 .session_records
8050 .lock()
8051 .unwrap_or_else(|poisoned| poisoned.into_inner());
8052 let record = records.records.get_mut(record_id).ok_or_else(|| {
8053 failure(
8054 NodeFailureCode::UnknownSessionRecord,
8055 "managed session record does not exist",
8056 )
8057 })?;
8058 let previous = record.clone();
8059 record.display_name = display_name;
8060 record.updated_at_unix_ms = unix_time_ms();
8061 previous
8062 };
8063 if let Err(error) = self.persist_state_locked() {
8064 self.session_records
8065 .lock()
8066 .unwrap_or_else(|poisoned| poisoned.into_inner())
8067 .records
8068 .insert(record_id.clone(), previous);
8069 return Err(persistence_failure(error));
8070 }
8071 let updated = self.record(record_id)?;
8072 self.publish_record(updated.clone());
8073 Ok(updated)
8074 }
8075
8076 fn set_session_task(
8077 &self,
8078 record_id: &SessionRecordId,
8079 expected_revision: u64,
8080 target: SessionTaskTargetV1,
8081 ) -> Result<ManagedSessionRecord, NodeFailure> {
8082 let transaction = self
8083 .state_transaction
8084 .lock()
8085 .unwrap_or_else(|poisoned| poisoned.into_inner());
8086 let current = self.record(record_id)?;
8087 let current_task_id = current
8088 .task_binding
8089 .as_ref()
8090 .and_then(|binding| binding.task_id.as_ref());
8091 let current_revision = current
8092 .task_binding
8093 .as_ref()
8094 .map_or(0, |binding| binding.revision);
8095 if expected_revision != current_revision {
8096 return Err(failure(
8097 NodeFailureCode::SessionRecordConflict,
8098 "managed session task binding revision changed",
8099 ));
8100 }
8101 let exact = match &target {
8102 SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8103 SessionTaskTargetV1::Clear => current_task_id.is_none(),
8104 SessionTaskTargetV1::New => false,
8105 };
8106 if exact {
8107 return Ok(current);
8108 }
8109 let revision = current_revision.checked_add(1).ok_or_else(|| {
8110 failure(
8111 NodeFailureCode::SessionRecordConflict,
8112 "managed session task binding revision is exhausted",
8113 )
8114 })?;
8115 let task_id = match target {
8116 SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8117 SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8118 SessionTaskTargetV1::Clear => None,
8119 };
8120 let changed_at_unix_ms = unix_time_ms()
8121 .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8122 failure(
8123 NodeFailureCode::SessionRecordConflict,
8124 "managed session record timestamp is exhausted",
8125 )
8126 })?);
8127 let updated = {
8128 let mut records = self
8129 .session_records
8130 .lock()
8131 .unwrap_or_else(|poisoned| poisoned.into_inner());
8132 let record = records.records.get_mut(record_id).ok_or_else(|| {
8133 failure(
8134 NodeFailureCode::UnknownSessionRecord,
8135 "managed session record does not exist",
8136 )
8137 })?;
8138 record.task_binding = Some(SessionTaskBindingV1 {
8139 revision,
8140 task_id,
8141 changed_at_unix_ms,
8142 });
8143 record.updated_at_unix_ms = changed_at_unix_ms;
8144 record.clone()
8145 };
8146 if let Err(error) = self.persist_state_locked() {
8147 self.session_records
8148 .lock()
8149 .unwrap_or_else(|poisoned| poisoned.into_inner())
8150 .records
8151 .insert(record_id.clone(), current);
8152 return Err(persistence_failure(error));
8153 }
8154 drop(transaction);
8155 self.publish_record(updated.clone());
8156 Ok(updated)
8157 }
8158
8159 fn index_provider_session(
8160 &self,
8161 workspace_id: WorkspaceId,
8162 provider: AgentId,
8163 identity: ProviderSessionIdentity,
8164 display_name: String,
8165 ) -> Result<ManagedSessionRecord, NodeFailure> {
8166 self.index_provider_session_with_policy(
8167 workspace_id,
8168 provider,
8169 identity,
8170 display_name,
8171 false,
8172 )
8173 }
8174
8175 fn index_provider_session_with_policy(
8176 &self,
8177 workspace_id: WorkspaceId,
8178 provider: AgentId,
8179 identity: ProviderSessionIdentity,
8180 display_name: String,
8181 allow_validated_transcript_path: bool,
8182 ) -> Result<ManagedSessionRecord, NodeFailure> {
8183 validate_display_name(&display_name)
8184 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8185 identity
8186 .validate()
8187 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8188 if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8189 return Err(failure(
8190 NodeFailureCode::InvalidRequest,
8191 "provider session reference index v1 does not accept transcript paths",
8192 ));
8193 }
8194 let canonical_root = self.workspace_root(&workspace_id)?;
8195 if !self.enabled_providers.contains(&provider) {
8196 return Err(failure(
8197 NodeFailureCode::InvalidRequest,
8198 "provider is not enabled on this node",
8199 ));
8200 }
8201
8202 let transaction = self
8203 .state_transaction
8204 .lock()
8205 .unwrap_or_else(|poisoned| poisoned.into_inner());
8206 {
8207 let records = self
8208 .session_records
8209 .lock()
8210 .unwrap_or_else(|poisoned| poisoned.into_inner());
8211 if let Some(existing) = records.records.values().find(|candidate| {
8212 candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8213 candidate_identity.key == identity.key && candidate_identity.id == identity.id
8214 })
8215 }) {
8216 if existing.provider == provider && existing.workspace_id == workspace_id {
8217 return Ok(existing.clone());
8218 }
8219 return Err(failure(
8220 NodeFailureCode::SessionRecordConflict,
8221 "provider session reference conflicts with another provider or workspace",
8222 ));
8223 }
8224 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8225 return Err(failure(
8226 NodeFailureCode::BackendBusy,
8227 "managed session record capacity is full; forget an inactive record first",
8228 ));
8229 }
8230 }
8231
8232 let now = unix_time_ms();
8233 let record = ManagedSessionRecord {
8234 record_id: self.allocate_record_id()?,
8235 display_name,
8236 provider,
8237 mode: SessionMode::Pty,
8238 state: ManagedSessionState::Dormant,
8239 workspace_id,
8240 canonical_root: opaque_windows_path(canonical_root),
8241 provider_session: Some(identity),
8242 active_session: None,
8243 environment_profile: None,
8244 bundle: None,
8245 context_id: None,
8246 context: None,
8247 exported_context: None,
8248 task_binding: None,
8249 created_at_unix_ms: now,
8250 updated_at_unix_ms: now,
8251 last_error: None,
8252 };
8253 self.insert_record(record.clone())?;
8254 if let Err(error) = self.persist_state_locked() {
8255 self.remove_record_memory(&record.record_id);
8256 return Err(persistence_failure(error));
8257 }
8258 drop(transaction);
8259 self.publish_record(record.clone());
8260 Ok(record)
8261 }
8262
8263 fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8264 self.session_bindings
8265 .lock()
8266 .unwrap_or_else(|poisoned| poisoned.into_inner())
8267 .iter()
8268 .find_map(|(instance_id, binding)| {
8269 (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8270 workspace_id: binding.workspace_id.clone(),
8271 session: SessionKey {
8272 instance_id: *instance_id,
8273 generation: binding.generation,
8274 },
8275 })
8276 })
8277 }
8278
8279 async fn forget_session_record(
8280 &self,
8281 record_id: &SessionRecordId,
8282 ) -> Result<(), NodeFailure> {
8283 let record = self.record(record_id)?;
8284 if record.active_session.is_some()
8285 || matches!(
8286 record.state,
8287 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8288 )
8289 {
8290 return Err(failure(
8291 NodeFailureCode::SessionRecordBusy,
8292 "stop the managed session before forgetting it",
8293 ));
8294 }
8295 if let Some(address) = self.bound_address_for_record(record_id) {
8296 self.remove_session(&address).await?;
8297 }
8298 self.remove_managed_session_record(record_id, &record.workspace_id).await
8299 }
8300
8301 async fn remove_managed_session_record(
8316 &self,
8317 record_id: &SessionRecordId,
8318 workspace_id: &WorkspaceId,
8319 ) -> Result<(), NodeFailure> {
8320 let managed_lease_id = self.managed_worktrees.lock()
8321 .unwrap_or_else(|poisoned| poisoned.into_inner())
8322 .lease_for_workspace(workspace_id);
8323 if !self.destroy_record_materialization(record_id)? {
8324 let transaction = self
8325 .state_transaction
8326 .lock()
8327 .unwrap_or_else(|poisoned| poisoned.into_inner());
8328 let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8329 failure(
8330 NodeFailureCode::UnknownSessionRecord,
8331 "managed session record does not exist",
8332 )
8333 })?;
8334 if let Err(error) = self.persist_state_locked() {
8335 let _ = self.insert_record(removed);
8336 return Err(persistence_failure(error));
8337 }
8338 drop(transaction);
8339 }
8340 self.publish(NodeEvent::SessionRecordRemoved {
8341 record_id: record_id.clone(),
8342 });
8343 if let Some(lease_id) = managed_lease_id {
8344 if let Some(snapshot) = self.managed_worktrees.lock()
8345 .unwrap_or_else(|poisoned| poisoned.into_inner())
8346 .get(&lease_id)
8347 .map(ManagedWorktreeLeaseRecord::snapshot)
8348 {
8349 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8350 }
8351 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8352 }
8353 Ok(())
8354 }
8355
8356 async fn retire_unavailable_session_records(&self) {
8369 let policy = self.session_record_retention;
8370 let age_enabled = policy.age_ms > 0;
8371 let keep_enabled = policy.keep_per_workspace > 0;
8372 if !age_enabled && !keep_enabled {
8373 return;
8374 }
8375 let now = unix_time_ms();
8376 let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8377 self.session_records
8378 .lock()
8379 .unwrap_or_else(|poisoned| poisoned.into_inner())
8380 .records
8381 .values()
8382 .filter(|record| record.state == ManagedSessionState::Unavailable)
8383 .map(|record| {
8384 (
8385 record.record_id.clone(),
8386 record.workspace_id.clone(),
8387 record.provider.clone(),
8388 record.updated_at_unix_ms,
8389 )
8390 })
8391 .collect()
8392 };
8393 if candidates.is_empty() {
8394 return;
8395 }
8396 let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8397 BTreeMap::new();
8398 for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8399 by_workspace
8400 .entry(workspace_id)
8401 .or_default()
8402 .push((record_id, provider, updated_at_unix_ms));
8403 }
8404 let reason = if age_enabled && keep_enabled {
8405 "age+keep-n"
8406 } else if age_enabled {
8407 "age"
8408 } else {
8409 "keep-n"
8410 };
8411 let mut retired: u64 = 0;
8412 for (workspace_id, mut records) in by_workspace {
8413 records.sort_by(|left, right| right.2.cmp(&left.2));
8416 for (index, (record_id, provider, updated_at_unix_ms)) in
8417 records.into_iter().enumerate()
8418 {
8419 let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8420 let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8421 let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8422 let should_retire = if age_enabled && keep_enabled {
8423 age_hit && keep_hit
8424 } else {
8425 age_hit || keep_hit
8426 };
8427 if !should_retire {
8428 continue;
8429 }
8430 match self.remove_managed_session_record(&record_id, &workspace_id).await {
8431 Ok(()) => {
8432 self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8433 retired += 1;
8434 tracing::info!(
8435 node_id = %self.node_id,
8436 record_id = %record_id,
8437 workspace_id = %workspace_id,
8438 provider = %provider,
8439 age_ms = record_age_ms,
8440 reason,
8441 "managed session record retired"
8442 );
8443 }
8444 Err(error) => tracing::warn!(
8445 node_id = %self.node_id,
8446 record_id = %record_id,
8447 workspace_id = %workspace_id,
8448 code = ?error.code,
8449 cause = %error.message,
8450 "session record retention sweep failed to retire a record"
8451 ),
8452 }
8453 }
8454 }
8455 if retired > 0 {
8456 let remaining_unavailable = self
8457 .session_records
8458 .lock()
8459 .unwrap_or_else(|poisoned| poisoned.into_inner())
8460 .records
8461 .values()
8462 .filter(|record| record.state == ManagedSessionState::Unavailable)
8463 .count();
8464 tracing::info!(
8465 node_id = %self.node_id,
8466 retired,
8467 remaining_unavailable,
8468 "session record retention sweep completed"
8469 );
8470 }
8471 }
8472
8473 async fn resume_session_record(
8474 &self,
8475 record_id: &SessionRecordId,
8476 terminal_size: gate4agent_types::TerminalSize,
8477 initial_prompt: Option<String>,
8478 ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8479 let record = self.record(record_id)?;
8480 if record.active_session.is_some()
8481 || matches!(
8482 record.state,
8483 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8484 )
8485 {
8486 return Err(failure(
8487 NodeFailureCode::SessionRecordBusy,
8488 "managed session already has a live runtime binding",
8489 ));
8490 }
8491 let identity = record.provider_session.clone().ok_or_else(|| {
8492 failure(
8493 NodeFailureCode::SessionRecordNotResumable,
8494 "managed session has no verified provider session identity",
8495 )
8496 })?;
8497 let working_directory = self.workspace_root(&record.workspace_id)?;
8498 if !platform::roots_equal(
8499 &working_directory,
8500 windows_path_text(&record.canonical_root),
8501 ) {
8502 return Err(failure(
8503 NodeFailureCode::SessionRecordConflict,
8504 "managed session workspace root changed; refusing to resume in another directory",
8505 ));
8506 }
8507 {
8508 let records = self
8509 .session_records
8510 .lock()
8511 .unwrap_or_else(|poisoned| poisoned.into_inner());
8512 if records.records.values().any(|candidate| {
8513 candidate.record_id != *record_id
8514 && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515 session_registry::same_provider_session(
8516 &candidate.provider,
8517 candidate_identity,
8518 &record.provider,
8519 &identity,
8520 )
8521 })
8522 && (candidate.active_session.is_some()
8523 || matches!(
8524 candidate.state,
8525 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8526 ))
8527 }) {
8528 return Err(failure(
8529 NodeFailureCode::SessionRecordConflict,
8530 "provider session is already active through another managed record",
8531 ));
8532 }
8533 }
8534 let runtime_requirement = if initial_prompt.is_some() {
8535 ProviderRuntimeRequirement::ResumeWithPrompt
8536 } else {
8537 ProviderRuntimeRequirement::Resume
8538 };
8539 let runtime_policy = self.admit_provider_runtime(
8540 &record.provider,
8541 runtime_requirement,
8542 )
8543 .await?;
8544 let request = ResumeLaunchRequest {
8545 working_directory,
8546 terminal_size,
8547 initial_prompt,
8548 };
8549 request
8550 .validate()
8551 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8552 self.ensure_binding_capacity()?;
8553 let resolved_materialization = match self.resolve_record_materialization(
8554 record_id,
8555 &record.provider,
8556 record.mode,
8557 record.environment_profile.as_ref(),
8558 record.bundle.as_ref(),
8559 record.context.as_ref(),
8560 ) {
8561 Ok(materialization) => materialization,
8562 Err(error) => {
8563 let label = match error.code {
8564 NodeFailureCode::UnknownBundle => "bundle-unavailable",
8565 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8566 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8567 _ => "environment-profile-unavailable",
8568 };
8569 self.mark_record_error(record_id, label)?;
8570 return Err(error);
8571 }
8572 };
8573 let (materialization_id, environment_overlay) = match resolved_materialization {
8574 Some((id, overlay)) => (Some(id), overlay),
8575 None => (None, None),
8576 };
8577 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8578 let mut environment_selection = match SpawnPreparer::select_environment(self,
8579 instance_id,
8580 &record.provider,
8581 record.mode,
8582 record.environment_profile.as_ref(),
8583 ) {
8584 Ok(selection) => selection,
8585 Err(error) => {
8586 self.mark_record_error(record_id, "environment-profile-unavailable")?;
8587 return Err(error);
8588 }
8589 };
8590 let mut instance_overlay = environment_overlay
8591 .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8592 .transpose()?
8593 .flatten();
8594 if let Some(stale_address) = self.bound_address_for_record(record_id) {
8595 self.remove_session(&stale_address).await?;
8596 }
8597 let address = SessionAddress {
8598 workspace_id: record.workspace_id.clone(),
8599 session: SessionKey {
8600 instance_id,
8601 generation: SessionGeneration(1),
8602 },
8603 };
8604 let transaction = self
8605 .state_transaction
8606 .lock()
8607 .unwrap_or_else(|poisoned| poisoned.into_inner());
8608 let current = self.record(record_id)?;
8609 if current.active_session.is_some()
8610 || matches!(
8611 current.state,
8612 ManagedSessionState::Live | ManagedSessionState::IdentityPending
8613 )
8614 {
8615 return Err(failure(
8616 NodeFailureCode::SessionRecordBusy,
8617 "managed session became active while resume was being prepared",
8618 ));
8619 }
8620 self.bind_managed_session_with_materialization(
8621 &address,
8622 record_id.clone(),
8623 runtime_policy,
8624 record.environment_profile.clone(),
8625 record.bundle.clone(),
8626 record.context.clone(),
8627 materialization_id,
8628 );
8629 if let Some(selection) = environment_selection.take() {
8630 selection.retain();
8631 }
8632 if let Some(overlay) = instance_overlay.take() {
8633 overlay.retain();
8634 }
8635 drop(transaction);
8636
8637 let transport = match record.mode {
8638 SessionMode::Pty => TransportKind::Pty,
8639 SessionMode::Inline => TransportKind::Pipe,
8640 SessionMode::Acp => TransportKind::Acp,
8641 };
8642 let agent_id = record.provider.clone();
8643 let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
8644 if let Err(error) = self
8645 .dispatch_bounded(
8646 ControlCommand::Register {
8647 instance_id,
8648 agent_id,
8649 transport,
8650 },
8651 dispatch_timeout,
8652 )
8653 .await
8654 {
8655 if let Some(binding) = self.remove_binding(&address) {
8656 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
8657 }
8658 return Err(error);
8659 }
8660 let command = self.prepare_command(ControlCommand::Resume {
8661 instance_id,
8662 target: ResumeTarget::ProviderSession { identity },
8663 runtime_policy,
8664 request,
8665 });
8666 let started_after = self.current_sequence();
8667 if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
8668 self.rollback_spawn(&address, dispatch_timeout).await?;
8669 return Err(error);
8670 }
8671 if let Err(error) = self.dispatch_envelope(command) {
8672 self.clear_armed_resume(&address);
8673 self.rollback_spawn(&address, dispatch_timeout).await?;
8674 return Err(error);
8675 }
8676 match self
8677 .wait_for_record_resume(&address, started_after)
8678 .await
8679 {
8680 Ok(settled_address) => {
8681 let managed_lease_id = {
8682 self.managed_worktrees.lock()
8683 .unwrap_or_else(|poisoned| poisoned.into_inner())
8684 .lease_for_workspace(&record.workspace_id)
8685 };
8686 if let Some(lease_id) = managed_lease_id {
8687 {
8688 let mut bindings = self.session_bindings.lock()
8689 .unwrap_or_else(|poisoned| poisoned.into_inner());
8690 let binding = bindings.get_mut(&settled_address.session.instance_id)
8691 .ok_or_else(|| failure(
8692 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8693 "resumed managed session lost its exact binding",
8694 ))?;
8695 if binding.generation != settled_address.session.generation {
8696 return Err(failure(
8697 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8698 "resumed managed session generation diverged",
8699 ));
8700 }
8701 binding.managed_worktree_lease_id = Some(lease_id.clone());
8702 }
8703 let snapshot = self.managed_worktrees.lock()
8704 .unwrap_or_else(|poisoned| poisoned.into_inner())
8705 .bind_session(
8706 &lease_id,
8707 ManagedWorktreeSessionHolder {
8708 incarnation_id: self.incarnation_id,
8709 instance_id: settled_address.session.instance_id,
8710 generation: settled_address.session.generation,
8711 },
8712 Some(record_id.clone()),
8713 unix_time_ms(),
8714 ).map_err(|message| failure(
8715 NodeFailureCode::ManagedWorktreeRecoveryRequired,
8716 &message,
8717 ))?;
8718 if let Err(error) = self.persist_state() {
8719 let _ = self.remove_session(&settled_address).await;
8720 return Err(persistence_failure(error));
8721 }
8722 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8723 }
8724 Ok((self.record(record_id)?, settled_address))
8725 }
8726 Err(error) => {
8727 if matches!(
8728 error.code,
8729 NodeFailureCode::SessionRecordNotResumable
8730 | NodeFailureCode::BackendOperationFailed
8731 ) {
8732 if let Some(bound) = self.bound_address_for_record(record_id) {
8733 let _ = self.remove_session(&bound).await;
8734 }
8735 }
8736 Err(error)
8737 }
8738 }
8739 }
8740
8741 async fn wait_for_record_resume(
8742 &self,
8743 initial_address: &SessionAddress,
8744 after_sequence: u64,
8745 ) -> Result<SessionAddress, NodeFailure> {
8746 let deadline = Instant::now()
8747 + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
8748 let mut scan_after = after_sequence;
8749 loop {
8750 let events = self
8751 .history
8752 .lock()
8753 .unwrap_or_else(|poisoned| poisoned.into_inner())
8754 .events
8755 .iter()
8756 .filter(|event| event.sequence > scan_after)
8757 .cloned()
8758 .collect::<Vec<_>>();
8759 for envelope in events {
8760 scan_after = scan_after.max(envelope.sequence);
8761 let NodeEvent::Control { address, event } = envelope.event else {
8762 continue;
8763 };
8764 if address.session.instance_id != initial_address.session.instance_id {
8765 continue;
8766 }
8767 match event.event {
8768 ControlEventKind::Resumed { .. } => return Ok(address),
8769 ControlEventKind::ResumeDenied { reason } => {
8770 return Err(failure(
8771 NodeFailureCode::SessionRecordNotResumable,
8772 &reason,
8773 ));
8774 }
8775 ControlEventKind::ResumeFailed { message }
8776 | ControlEventKind::CommandRejected { message } => {
8777 return Err(failure(
8778 NodeFailureCode::BackendOperationFailed,
8779 &message,
8780 ));
8781 }
8782 _ => {}
8783 }
8784 }
8785 if Instant::now() >= deadline {
8786 return Err(failure(
8787 NodeFailureCode::BackendBusy,
8788 "managed resume did not settle before the bounded deadline",
8789 ));
8790 }
8791 sleep(Duration::from_millis(5)).await;
8792 }
8793 }
8794
8795 fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
8796 self.workspaces
8797 .read()
8798 .unwrap_or_else(|poisoned| poisoned.into_inner())
8799 .get(workspace_id)
8800 .cloned()
8801 .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
8802 }
8803
8804 fn workspace_roots(&self) -> Vec<PathBuf> {
8805 self.workspaces
8806 .read()
8807 .unwrap_or_else(|poisoned| poisoned.into_inner())
8808 .values()
8809 .map(PathBuf::from)
8810 .collect()
8811 }
8812
8813 async fn inspect_workspace(
8814 &self,
8815 workspace_id: WorkspaceId,
8816 ) -> Result<WorkspaceInspection, NodeFailure> {
8817 let _permit = self
8818 .inspection_slots
8819 .clone()
8820 .try_acquire_owned()
8821 .map_err(|_| {
8822 tracing::warn!(
8823 workspace_id = %workspace_id,
8824 "workspace inspection rejected: inspection capacity is busy",
8825 );
8826 failure(
8827 NodeFailureCode::BackendBusy,
8828 "workspace inspection capacity is busy",
8829 )
8830 })?;
8831 let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
8832 tracing::warn!(
8833 workspace_id = %workspace_id,
8834 code = ?error.code,
8835 "workspace inspection rejected: workspace is not registered",
8836 );
8837 error
8838 })?;
8839 let tree_root = canonical_root.clone();
8840 let started_at = Instant::now();
8841 let time_budget_ms = workspace_inspection_time_budget_ms();
8842 let entry_cap = workspace_inspection_entry_cap();
8843 let deadline = started_at + Duration::from_millis(time_budget_ms);
8844 let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
8845 collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
8846 })
8847 .await
8848 .map_err(|error| {
8849 tracing::warn!(
8850 workspace_id = %workspace_id,
8851 error = %error,
8852 "workspace inspection rejected: walk task failed",
8853 );
8854 failure(
8855 NodeFailureCode::BackendOperationFailed,
8856 &format!("workspace inspection task failed: {error}"),
8857 )
8858 })?;
8859 let (mut git, git_time_budget_exceeded) =
8860 inspect_git_workspace(&canonical_root, deadline).await;
8861 if !git.worktrees.is_empty() {
8862 let registered = self
8863 .workspaces
8864 .read()
8865 .unwrap_or_else(|poisoned| poisoned.into_inner())
8866 .iter()
8867 .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
8868 .collect::<Vec<_>>();
8869 for worktree in &mut git.worktrees {
8870 worktree.workspace_id = registered
8871 .iter()
8872 .find(|(_, root)| {
8873 worktree_paths_equal(root, windows_path_text(&worktree.path))
8874 })
8875 .map(|(workspace_id, _)| workspace_id.clone());
8876 }
8877 }
8878 if git.is_repository {
8879 git.managed_worktree = self
8880 .managed_worktrees
8881 .lock()
8882 .unwrap_or_else(|poisoned| poisoned.into_inner())
8883 .git_scope(&workspace_id, git.branch.as_deref());
8884 }
8885 let truncation = workspace_inspection_truncation(
8886 &walk_budget,
8887 git_time_budget_exceeded,
8888 started_at.elapsed(),
8889 );
8890 if let Some(truncation) = &truncation {
8891 tracing::info!(
8892 workspace_id = %workspace_id,
8893 walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
8894 walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
8895 git_time_budget_exceeded = truncation.git_time_budget_exceeded,
8896 entries_visited = truncation.entries_visited,
8897 elapsed_ms = truncation.elapsed_ms,
8898 "workspace inspection truncated by its inner budget",
8899 );
8900 }
8901 Ok(WorkspaceInspection {
8902 workspace_id,
8903 entries,
8904 tree_truncated,
8905 git,
8906 truncation,
8907 })
8908 }
8909
8910 async fn browse_host_directories(
8911 &self,
8912 directory: Option<OpaqueHostPath>,
8913 after: Option<OpaqueHostPath>,
8914 ) -> Result<HostDirectoryListing, NodeFailure> {
8915 let permit = self
8916 .inspection_slots
8917 .clone()
8918 .try_acquire_owned()
8919 .map_err(|_| {
8920 failure(
8921 NodeFailureCode::BackendBusy,
8922 "host directory browse capacity is busy",
8923 )
8924 })?;
8925 let task = tokio::task::spawn_blocking(move || {
8926 let _permit = permit;
8927 browse_host_directories(directory, after)
8928 });
8929 timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
8930 .await
8931 .map_err(|_| {
8932 failure(
8933 NodeFailureCode::HostDirectoryReadTimedOut,
8934 "host directory browse exceeded its bounded deadline",
8935 )
8936 })?
8937 .map_err(|_| {
8938 failure(
8939 NodeFailureCode::HostDirectoryReadFailed,
8940 "host directory browse task failed",
8941 )
8942 })?
8943 .map_err(host_directory_failure)
8944 }
8945
8946 async fn read_workspace_file(
8947 &self,
8948 workspace_id: WorkspaceId,
8949 path: RepositoryPath,
8950 ) -> Result<WorkspaceFileRead, NodeFailure> {
8951 let canonical_root = self.workspace_root(&workspace_id)?;
8952 let permit = self
8953 .inspection_slots
8954 .clone()
8955 .try_acquire_owned()
8956 .map_err(|_| {
8957 failure(
8958 NodeFailureCode::BackendBusy,
8959 "workspace file read capacity is busy",
8960 )
8961 })?;
8962 let path_to_read = path.clone();
8963 let task = tokio::task::spawn_blocking(move || {
8964 let _permit = permit;
8965 read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
8966 });
8967 let content = timeout(
8968 Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
8969 task,
8970 )
8971 .await
8972 .map_err(|_| {
8973 failure(
8974 NodeFailureCode::RepositoryFileReadTimedOut,
8975 "repository file read exceeded its bounded deadline",
8976 )
8977 })?
8978 .map_err(|_| {
8979 failure(
8980 NodeFailureCode::RepositoryFileReadFailed,
8981 "repository file read task failed",
8982 )
8983 })?
8984 .map_err(workspace_file_failure)?;
8985 let content = match content {
8986 WorkspaceFileBytes::Utf8(text) => {
8987 let revision = Some(workspace_file_revision(text.as_bytes()));
8988 return Ok(WorkspaceFileRead {
8989 workspace_id,
8990 path,
8991 content: WorkspaceFileContent::Utf8 {
8992 byte_len: u32::try_from(text.len())
8993 .expect("bounded workspace text length must fit u32"),
8994 text,
8995 },
8996 revision,
8997 });
8998 }
8999 WorkspaceFileBytes::NonUtf8 { byte_length } => {
9000 WorkspaceFileContent::NonUtf8 {
9001 byte_len: u32::try_from(byte_length)
9002 .expect("bounded workspace file length must fit u32"),
9003 }
9004 }
9005 WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9006 limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9007 },
9008 };
9009 Ok(WorkspaceFileRead {
9010 workspace_id,
9011 path,
9012 content,
9013 revision: None,
9014 })
9015 }
9016
9017 async fn write_workspace_file(
9018 &self,
9019 workspace_id: WorkspaceId,
9020 path: RepositoryPath,
9021 expected_revision: WorkspaceFileRevision,
9022 text: String,
9023 ) -> Result<WorkspaceFileRead, NodeFailure> {
9024 let canonical_root = self.workspace_root(&workspace_id)?;
9025 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9026 failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9027 })?;
9028 let path_to_write = path.clone();
9029 let expected = expected_revision.as_str().to_owned();
9030 let text_to_write = text.clone();
9031 let task = tokio::task::spawn_blocking(move || {
9032 let _permit = permit;
9033 write_workspace_file_to_disk(
9034 Path::new(&canonical_root),
9035 &path_to_write,
9036 &expected,
9037 &text_to_write,
9038 )
9039 });
9040 let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9041 .await
9042 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9043 .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9044 .map_err(workspace_file_write_failure)?;
9045 Ok(WorkspaceFileRead {
9046 workspace_id,
9047 path,
9048 content: WorkspaceFileContent::Utf8 {
9049 byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9050 text,
9051 },
9052 revision: Some(WorkspaceFileRevision::new(revision)
9053 .expect("workspace writer returns a valid SHA-256 revision")),
9054 })
9055 }
9056
9057 async fn create_workspace_file(
9058 &self,
9059 workspace_id: WorkspaceId,
9060 path: RepositoryPath,
9061 ) -> Result<WorkspaceFileRead, NodeFailure> {
9062 let canonical_root = self.workspace_root(&workspace_id)?;
9063 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9064 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9065 })?;
9066 let path_to_create = path.clone();
9067 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9068 let task_commit_state = Arc::clone(&commit_state);
9069 let task = tokio::task::spawn_blocking(move || {
9070 let _permit = permit;
9071 create_workspace_file_on_disk(
9072 Path::new(&canonical_root),
9073 &path_to_create,
9074 &task_commit_state,
9075 )
9076 });
9077 let revision = settle_workspace_entry_create(
9078 task,
9079 commit_state,
9080 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9081 "repository file create exceeded its bounded deadline",
9082 "repository file create task failed",
9083 )
9084 .await?;
9085 Ok(WorkspaceFileRead {
9086 workspace_id,
9087 path,
9088 content: WorkspaceFileContent::Utf8 {
9089 text: String::new(),
9090 byte_len: 0,
9091 },
9092 revision: Some(WorkspaceFileRevision::new(revision)
9093 .expect("workspace creator returns a valid SHA-256 revision")),
9094 })
9095 }
9096
9097 async fn create_workspace_directory(
9098 &self,
9099 workspace_id: WorkspaceId,
9100 path: RepositoryPath,
9101 ) -> Result<WorkspaceEntry, NodeFailure> {
9102 let canonical_root = self.workspace_root(&workspace_id)?;
9103 let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9104 failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9105 })?;
9106 let path_to_create = path.clone();
9107 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9108 let task_commit_state = Arc::clone(&commit_state);
9109 let task = tokio::task::spawn_blocking(move || {
9110 let _permit = permit;
9111 create_workspace_directory_on_disk(
9112 Path::new(&canonical_root),
9113 &path_to_create,
9114 &task_commit_state,
9115 )
9116 });
9117 settle_workspace_entry_create(
9118 task,
9119 commit_state,
9120 Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9121 "repository directory create exceeded its bounded deadline",
9122 "repository directory create task failed",
9123 )
9124 .await?;
9125 Ok(WorkspaceEntry {
9126 relative_path: path,
9127 kind: WorkspaceEntryKind::Directory,
9128 })
9129 }
9130
9131 async fn read_git_history(
9132 &self,
9133 workspace_id: WorkspaceId,
9134 path: Option<RepositoryPath>,
9135 before: Option<GitObjectId>,
9136 limit: u16,
9137 ) -> Result<GitHistoryPage, NodeFailure> {
9138 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9139 failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9140 })?;
9141 let root = self.workspace_root(&workspace_id)?;
9142 read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9143 }
9144
9145 async fn read_git_diff(
9146 &self,
9147 workspace_id: WorkspaceId,
9148 request: GitDiffRequest,
9149 ) -> Result<GitDiff, NodeFailure> {
9150 let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9151 failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9152 })?;
9153 let root = self.workspace_root(&workspace_id)?;
9154 read_git_diff_bounded(&root, request).await
9155 }
9156
9157 fn native_session_route_root(
9158 &self,
9159 route: &NativeSessionCatalogRoute,
9160 ) -> Result<Option<String>, NodeFailure> {
9161 route
9162 .validate()
9163 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9164 match route.scope {
9165 gate4agent_types::NativeSessionCatalogScope::Workspace => route
9166 .workspace_id
9167 .as_ref()
9168 .map(|workspace_id| self.workspace_root(workspace_id))
9169 .transpose(),
9170 gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9171 }
9172 }
9173
9174 fn project_native_session_entries(
9175 &self,
9176 route: &NativeSessionCatalogRoute,
9177 entries: Vec<ScopedNativeSessionCatalogEntry>,
9178 ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9179 let current_root = route
9180 .workspace_id
9181 .as_ref()
9182 .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9183 let records = self
9184 .session_records
9185 .lock()
9186 .unwrap_or_else(|poisoned| poisoned.into_inner());
9187 let mut seen_records = std::collections::HashSet::new();
9188 entries
9189 .into_iter()
9190 .map(|entry| {
9191 let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9192 let matches = records
9193 .records
9194 .values()
9195 .filter(|record| {
9196 record.provider == route.provider
9197 && record.workspace_id == *workspace_id
9198 && current_root.as_deref().is_some_and(|root| {
9199 platform::roots_equal(
9200 root,
9201 windows_path_text(&record.canonical_root),
9202 )
9203 })
9204 && record.provider_session.as_ref().is_some_and(|identity| {
9205 session_registry::same_provider_session(
9206 &record.provider,
9207 identity,
9208 &route.provider,
9209 &entry.provider_session,
9210 )
9211 })
9212 })
9213 .map(|record| record.record_id.clone())
9214 .collect::<Vec<_>>();
9215 (matches.len() == 1
9216 && seen_records.insert(matches[0].clone()))
9217 .then(|| matches[0].clone())
9218 });
9219 let metadata = entry.metadata;
9220 crate::protocol::NativeSessionCatalogEntry {
9221 selection_id: metadata.selection_id,
9222 title: metadata.title,
9223 modified_at_unix_ms: metadata.modified_at_unix_ms,
9224 model: metadata.model,
9225 message_count: metadata.message_count,
9226 completed_turn_count: metadata.completed_turn_count,
9227 external_group: entry.external_group,
9228 record_id,
9229 }
9230 })
9231 .collect()
9232 }
9233
9234 async fn catalog_native_sessions(
9235 &self,
9236 route: NativeSessionCatalogRoute,
9237 limit: u16,
9238 ) -> Result<(
9239 Vec<crate::protocol::NativeSessionCatalogEntry>,
9240 crate::protocol::NativeSessionCatalogSummary,
9241 ), NodeFailure> {
9242 let canonical_root = self.native_session_route_root(&route)?;
9243 let registered_workspace_roots = self.workspace_roots();
9244 let provider = route.provider.clone();
9245 let scope = route.scope;
9246 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9247 failure(
9248 NodeFailureCode::InvalidRequest,
9249 "native session catalog is not configured",
9250 )
9251 })?;
9252 let _permit = Arc::clone(&self.inspection_slots)
9253 .try_acquire_owned()
9254 .map_err(|_| {
9255 failure(
9256 NodeFailureCode::BackendBusy,
9257 "native session catalog capacity is busy",
9258 )
9259 })?;
9260 let page = run_native_session_catalog_operation(
9261 catalog,
9262 "native session catalog",
9263 move |catalog| {
9264 catalog.catalog_initial_for_scope(
9265 &provider,
9266 scope,
9267 canonical_root.as_deref().map(Path::new),
9268 ®istered_workspace_roots,
9269 limit,
9270 )
9271 },
9272 )
9273 .await?;
9274 let summary = crate::protocol::NativeSessionCatalogSummary {
9275 catalog_revision: page.revision,
9276 recent_cutoff_unix_ms: page.cutoff_unix_ms,
9277 recent_total_count: page.recent_total_count,
9278 older_total_count: page.older_total_count,
9279 recent_next_after_selection_id: page.next_after_selection_id,
9280 recent_has_more: page.remaining_count > 0,
9281 };
9282 Ok((self.project_native_session_entries(&route, page.entries), summary))
9283 }
9284
9285 async fn page_native_sessions(
9286 &self,
9287 route: NativeSessionCatalogRoute,
9288 window: crate::protocol::NativeSessionCatalogWindow,
9289 catalog_revision: u64,
9290 recent_cutoff_unix_ms: u64,
9291 after_selection_id: Option<String>,
9292 limit: u16,
9293 ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9294 let canonical_root = self.native_session_route_root(&route)?;
9295 let registered_workspace_roots = self.workspace_roots();
9296 let provider = route.provider.clone();
9297 let scope = route.scope;
9298 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9299 failure(
9300 NodeFailureCode::InvalidRequest,
9301 "native session catalog is not configured",
9302 )
9303 })?;
9304 let _permit = Arc::clone(&self.inspection_slots)
9305 .try_acquire_owned()
9306 .map_err(|_| {
9307 failure(
9308 NodeFailureCode::BackendBusy,
9309 "native session catalog capacity is busy",
9310 )
9311 })?;
9312 let page = run_native_session_catalog_operation(
9313 catalog,
9314 "native session catalog page",
9315 move |catalog| {
9316 Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9317 &provider,
9318 scope,
9319 canonical_root.as_deref().map(Path::new),
9320 ®istered_workspace_roots,
9321 window,
9322 catalog_revision,
9323 recent_cutoff_unix_ms,
9324 after_selection_id.as_deref(),
9325 limit,
9326 ))
9327 },
9328 )
9329 .await?;
9330 let page = page.map_err(|error| match error {
9331 NativeSessionCatalogError::StaleCatalog => failure(
9332 NodeFailureCode::StaleNativeSessionCatalog,
9333 "native session catalog revision or cursor is stale",
9334 ),
9335 _ => failure(
9336 NodeFailureCode::BackendOperationFailed,
9337 "native session catalog page failed",
9338 ),
9339 })?;
9340 let entries = self.project_native_session_entries(&route, page.entries);
9341 Ok(crate::protocol::NativeSessionCatalogPage {
9342 window: page.window,
9343 revision: page.revision,
9344 entries,
9345 next_after_selection_id: page.next_after_selection_id,
9346 remaining_count: page.remaining_count,
9347 has_more: page.remaining_count > 0,
9348 })
9349 }
9350
9351 async fn preview_native_session(
9352 &self,
9353 selection: NativeSessionSelection,
9354 message_limit: u16,
9355 ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9356 selection
9357 .validate()
9358 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9359 let canonical_root = self.native_session_route_root(&selection.route)?;
9360 let registered_workspace_roots = self.workspace_roots();
9361 let provider = selection.route.provider.clone();
9362 let scope = selection.route.scope;
9363 let catalog_revision = selection.catalog_revision;
9364 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9365 let selection_id = selection.selection_id.clone();
9366 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9367 failure(
9368 NodeFailureCode::InvalidRequest,
9369 "native session preview is not configured",
9370 )
9371 })?;
9372 let _permit = Arc::clone(&self.inspection_slots)
9373 .try_acquire_owned()
9374 .map_err(|_| {
9375 failure(
9376 NodeFailureCode::BackendBusy,
9377 "native session preview capacity is busy",
9378 )
9379 })?;
9380 let preview = run_native_session_catalog_operation(
9381 catalog,
9382 "native session preview",
9383 move |catalog| {
9384 Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9385 &provider,
9386 scope,
9387 canonical_root.as_deref().map(Path::new),
9388 ®istered_workspace_roots,
9389 catalog_revision,
9390 recent_cutoff_unix_ms,
9391 &selection_id,
9392 message_limit,
9393 ))
9394 },
9395 )
9396 .await?;
9397 preview
9398 .map(Into::into)
9399 .map_err(native_session_preview_failure)
9400 }
9401
9402 async fn index_native_session(
9403 &self,
9404 selection: NativeSessionSelection,
9405 display_name: String,
9406 ) -> Result<ManagedSessionRecord, NodeFailure> {
9407 selection
9408 .validate()
9409 .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9410 if selection.route.scope
9411 != gate4agent_types::NativeSessionCatalogScope::Workspace
9412 {
9413 return Err(failure(
9414 NodeFailureCode::WorkspaceRegistrationRequired,
9415 "register the external project as a workspace before indexing or resuming it",
9416 ));
9417 }
9418 let workspace_id = selection
9419 .route
9420 .workspace_id
9421 .clone()
9422 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9423 let canonical_root = self
9424 .native_session_route_root(&selection.route)?
9425 .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9426 let registered_workspace_roots = self.workspace_roots();
9427 let provider = selection.route.provider.clone();
9428 let catalog_revision = selection.catalog_revision;
9429 let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9430 let selection_id = selection.selection_id.clone();
9431 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9432 failure(
9433 NodeFailureCode::InvalidRequest,
9434 "native session index is not configured",
9435 )
9436 })?;
9437 let _permit = Arc::clone(&self.inspection_slots)
9438 .try_acquire_owned()
9439 .map_err(|_| {
9440 failure(
9441 NodeFailureCode::BackendBusy,
9442 "native session index capacity is busy",
9443 )
9444 })?;
9445 let resolved = run_native_session_catalog_operation(
9446 catalog,
9447 "native session index resolution",
9448 move |catalog| {
9449 Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9450 &provider,
9451 gate4agent_types::NativeSessionCatalogScope::Workspace,
9452 Some(Path::new(&canonical_root)),
9453 ®istered_workspace_roots,
9454 catalog_revision,
9455 recent_cutoff_unix_ms,
9456 &selection_id,
9457 ))
9458 },
9459 )
9460 .await?
9461 .map_err(native_session_preview_failure)?;
9462 self.index_provider_session_with_policy(
9463 workspace_id,
9464 selection.route.provider,
9465 resolved.identity,
9466 display_name,
9467 true,
9468 )
9469 }
9470
9471 async fn preview_native_session_by_session_id(
9472 &self,
9473 workspace_id: WorkspaceId,
9474 provider: AgentId,
9475 session_id: String,
9476 message_limit: u16,
9477 ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9478 let canonical_root = self.workspace_root(&workspace_id)?;
9479 let registered_workspace_roots = self.workspace_roots();
9480 let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9481 failure(
9482 NodeFailureCode::InvalidRequest,
9483 "native session preview is not configured",
9484 )
9485 })?;
9486 let _permit = Arc::clone(&self.inspection_slots)
9487 .try_acquire_owned()
9488 .map_err(|_| {
9489 failure(
9490 NodeFailureCode::BackendBusy,
9491 "native session preview capacity is busy",
9492 )
9493 })?;
9494 run_native_session_catalog_operation(
9495 catalog,
9496 "native session preview",
9497 move |catalog| {
9498 catalog.preview_session_id_for_workspace(
9499 &provider,
9500 Path::new(&canonical_root),
9501 ®istered_workspace_roots,
9502 &session_id,
9503 message_limit,
9504 )
9505 },
9506 )
9507 .await
9508 }
9509
9510 async fn register_workspace(
9511 &self,
9512 workspace_id: WorkspaceId,
9513 root: String,
9514 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9515 validate_workspace_request_root(&root)?;
9516 let canonicalize_workspace_id = workspace_id.clone();
9517 let workspace = tokio::task::spawn_blocking(move || {
9518 WorkspaceConfig::new(canonicalize_workspace_id, root)
9519 })
9520 .await
9521 .map_err(|error| {
9522 failure(
9523 NodeFailureCode::BackendOperationFailed,
9524 &format!("workspace canonicalization task failed: {error}"),
9525 )
9526 })?
9527 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9528 let transaction = self
9529 .state_transaction
9530 .lock()
9531 .unwrap_or_else(|poisoned| poisoned.into_inner());
9532 let mut workspaces = self
9533 .workspaces
9534 .write()
9535 .unwrap_or_else(|poisoned| poisoned.into_inner());
9536 if workspaces.contains_key(&workspace_id) {
9537 return Err(failure(
9538 NodeFailureCode::DuplicateWorkspaceId,
9539 "workspace ID is already registered",
9540 ));
9541 }
9542 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9543 platform::roots_equal(existing_root, workspace.canonical_root())
9544 }) {
9545 return Err(failure(
9546 NodeFailureCode::DuplicateWorkspaceRoot,
9547 &format!("workspace root is already registered as '{existing_id}'"),
9548 ));
9549 }
9550 {
9551 let records = self
9552 .session_records
9553 .lock()
9554 .unwrap_or_else(|poisoned| poisoned.into_inner());
9555 if let Some(record) = records.records.values().find(|record| {
9556 record.workspace_id == workspace_id
9557 && !record
9558 .canonical_root
9559 .as_utf8()
9560 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9561 }) {
9562 return Err(failure(
9563 NodeFailureCode::SessionRecordConflict,
9564 &format!(
9565 "workspace ID is retained by session record '{}' at another root",
9566 record.record_id,
9567 ),
9568 ));
9569 }
9570 }
9571 let snapshot = WorkspaceSnapshot {
9572 workspace_id: workspace_id.clone(),
9573 canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9574 sessions: Vec::new(),
9575 worktree_service_mode: Some(WorktreeServiceMode::Manual),
9576 managed_worktree_profiles: Some(WorktreeProfileInventory {
9577 profiles: Vec::new(),
9578 }),
9579 };
9580 workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9581 drop(workspaces);
9582 let previous_records = {
9583 let mut records = self
9584 .session_records
9585 .lock()
9586 .unwrap_or_else(|poisoned| poisoned.into_inner());
9587 let previous = records.records.clone();
9588 for record in records.records.values_mut().filter(|record| {
9589 record.workspace_id == workspace_id
9590 && record
9591 .canonical_root
9592 .as_utf8()
9593 .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9594 }) {
9595 if record.active_session.is_none() {
9596 record.state = detached_record_state(record);
9597 }
9598 if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9599 record.last_error = None;
9600 }
9601 record.updated_at_unix_ms = unix_time_ms();
9602 }
9603 previous
9604 };
9605 if let Err(error) = self.persist_state_locked() {
9606 self.workspaces
9607 .write()
9608 .unwrap_or_else(|poisoned| poisoned.into_inner())
9609 .remove(&workspace_id);
9610 self.session_records
9611 .lock()
9612 .unwrap_or_else(|poisoned| poisoned.into_inner())
9613 .records = previous_records;
9614 return Err(persistence_failure(error));
9615 }
9616 let restored = self
9617 .session_records
9618 .lock()
9619 .unwrap_or_else(|poisoned| poisoned.into_inner())
9620 .records
9621 .values()
9622 .filter(|record| record.workspace_id == workspace_id)
9623 .cloned()
9624 .collect::<Vec<_>>();
9625 drop(transaction);
9626 self.publish(NodeEvent::WorkspaceAdded {
9627 workspace: snapshot.clone(),
9628 });
9629 for record in restored {
9630 self.publish_record(record);
9631 }
9632 Ok(snapshot)
9633 }
9634
9635 async fn create_standalone_workspace(
9636 &self,
9637 workspace_id: WorkspaceId,
9638 root: String,
9639 initial_branch: Option<String>,
9640 ) -> Result<WorkspaceSnapshot, NodeFailure> {
9641 validate_workspace_request_root(&root)?;
9642 validate_git_revision("initial branch", initial_branch.as_deref())?;
9643 let preflight_workspace_id = workspace_id.clone();
9644 let preflight_root = root.clone();
9645 let canonical_candidate = tokio::task::spawn_blocking(move || {
9646 canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
9647 })
9648 .await
9649 .map_err(|_| failure(
9650 NodeFailureCode::BackendOperationFailed,
9651 "standalone workspace root preflight task failed",
9652 ))?
9653 .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9654 {
9655 let workspaces = self
9656 .workspaces
9657 .read()
9658 .unwrap_or_else(|poisoned| poisoned.into_inner());
9659 if workspaces.contains_key(&workspace_id) {
9660 return Err(failure(
9661 NodeFailureCode::DuplicateWorkspaceId,
9662 "workspace ID is already registered",
9663 ));
9664 }
9665 if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9666 platform::roots_equal(existing_root, &canonical_candidate)
9667 }) {
9668 return Err(failure(
9669 NodeFailureCode::DuplicateWorkspaceRoot,
9670 &format!("workspace root is already registered as '{existing_id}'"),
9671 ));
9672 }
9673 }
9674 let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
9675 .await
9676 .map_err(standalone_workspace_failure)?;
9677 prepared
9678 .verify_for_registration()
9679 .await
9680 .map_err(standalone_workspace_failure)?;
9681 match self
9682 .register_workspace(workspace_id, prepared.root().to_owned())
9683 .await
9684 {
9685 Ok(workspace) => Ok(workspace),
9686 Err(registration_error) => {
9687 if prepared.compensate_registration_failure().await {
9688 Err(registration_error)
9689 } else {
9690 Err(failure(
9691 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
9692 "standalone workspace initialization succeeded but registration failed; recovery is required",
9693 ))
9694 }
9695 }
9696 }
9697 }
9698
9699 fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
9700 let bindings = self
9701 .session_bindings
9702 .lock()
9703 .unwrap_or_else(|poisoned| poisoned.into_inner());
9704 if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
9705 return Err(failure(
9706 NodeFailureCode::WorkspaceBusy,
9707 "workspace retains a bound session; remove every session before unregistering it",
9708 ));
9709 }
9710 drop(bindings);
9711 let transaction = self
9712 .state_transaction
9713 .lock()
9714 .unwrap_or_else(|poisoned| poisoned.into_inner());
9715 let mut workspaces = self
9716 .workspaces
9717 .write()
9718 .unwrap_or_else(|poisoned| poisoned.into_inner());
9719 if !workspaces.contains_key(workspace_id) {
9720 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
9721 }
9722 if workspaces.len() == 1 {
9723 return Err(failure(
9724 NodeFailureCode::LastWorkspace,
9725 "node must retain at least one workspace",
9726 ));
9727 }
9728 let removed_root = workspaces
9729 .remove(workspace_id)
9730 .expect("workspace presence was validated");
9731 drop(workspaces);
9732 let previous_records = {
9733 let mut records = self
9734 .session_records
9735 .lock()
9736 .unwrap_or_else(|poisoned| poisoned.into_inner());
9737 let previous = records.records.clone();
9738 for record in records
9739 .records
9740 .values_mut()
9741 .filter(|record| &record.workspace_id == workspace_id)
9742 {
9743 record.active_session = None;
9744 record.state = ManagedSessionState::Unavailable;
9745 record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
9746 record.updated_at_unix_ms = unix_time_ms();
9747 }
9748 previous
9749 };
9750 if let Err(error) = self.persist_state_locked() {
9751 self.workspaces
9752 .write()
9753 .unwrap_or_else(|poisoned| poisoned.into_inner())
9754 .insert(workspace_id.clone(), removed_root);
9755 self.session_records
9756 .lock()
9757 .unwrap_or_else(|poisoned| poisoned.into_inner())
9758 .records = previous_records;
9759 return Err(persistence_failure(error));
9760 }
9761 let unavailable = self
9762 .session_records
9763 .lock()
9764 .unwrap_or_else(|poisoned| poisoned.into_inner())
9765 .records
9766 .values()
9767 .filter(|record| &record.workspace_id == workspace_id)
9768 .cloned()
9769 .collect::<Vec<_>>();
9770 drop(transaction);
9771 self.publish(NodeEvent::WorkspaceRemoved {
9772 workspace_id: workspace_id.clone(),
9773 });
9774 for record in unavailable {
9775 self.publish_record(record);
9776 }
9777 Ok(())
9778 }
9779
9780 async fn create_worktree(
9781 &self,
9782 source_workspace_id: WorkspaceId,
9783 workspace_id: WorkspaceId,
9784 target_root: String,
9785 branch: String,
9786 base: Option<String>,
9787 ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
9788 self.require_worktree_service(&source_workspace_id)?;
9789 validate_workspace_request_root(&target_root)?;
9790 validate_git_revision("worktree base", base.as_deref())?;
9791 let source_root = self.workspace_root(&source_workspace_id)?;
9792 {
9793 let workspaces = self
9794 .workspaces
9795 .read()
9796 .unwrap_or_else(|poisoned| poisoned.into_inner());
9797 if workspaces.contains_key(&workspace_id) {
9798 return Err(failure(
9799 NodeFailureCode::DuplicateWorkspaceId,
9800 "workspace ID is already registered",
9801 ));
9802 }
9803 if let Some((existing_id, _)) = workspaces
9804 .iter()
9805 .find(|(_, root)| worktree_paths_equal(root, &target_root))
9806 {
9807 return Err(failure(
9808 NodeFailureCode::DuplicateWorkspaceRoot,
9809 &format!("workspace root is already registered as '{existing_id}'"),
9810 ));
9811 }
9812 }
9813 let mut worktree = create_git_worktree(
9814 &source_root,
9815 &target_root,
9816 &branch,
9817 base.as_deref(),
9818 )
9819 .await
9820 .map_err(git_worktree_failure)?;
9821 let workspace = match self
9822 .register_workspace(workspace_id, worktree.path.clone())
9823 .await
9824 {
9825 Ok(workspace) => workspace,
9826 Err(registration_error) => {
9827 if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
9828 return Err(failure(
9829 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9830 "worktree registration failed and compensation requires recovery",
9831 ));
9832 }
9833 return Err(failure(
9834 registration_error.code,
9835 ®istration_error.message,
9836 ));
9837 }
9838 };
9839 worktree.workspace_id = Some(workspace.workspace_id.clone());
9840 Ok((protocol_worktree(worktree), workspace))
9841 }
9842
9843 async fn remove_worktree(
9844 &self,
9845 source_workspace_id: WorkspaceId,
9846 target_root: String,
9847 ) -> Result<Option<WorkspaceId>, NodeFailure> {
9848 self.require_worktree_service(&source_workspace_id)?;
9849 validate_workspace_request_root(&target_root)?;
9850 let source_root = self.workspace_root(&source_workspace_id)?;
9851 let target_root = normalize_worktree_removal_target(&target_root)
9852 .map_err(git_worktree_failure)?;
9853 let target = list_git_worktrees(&source_root)
9854 .await
9855 .map_err(git_worktree_failure)?
9856 .into_iter()
9857 .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
9858 .ok_or_else(|| {
9859 failure(
9860 NodeFailureCode::WorktreeProtected,
9861 "refusing to remove a path that is not in Git's worktree listing",
9862 )
9863 })?;
9864 let registered_workspace_id = {
9865 let workspaces = self
9866 .workspaces
9867 .read()
9868 .unwrap_or_else(|poisoned| poisoned.into_inner());
9869 let matched = workspaces
9870 .iter()
9871 .find(|(_, root)| worktree_paths_equal(root, &target.path))
9872 .map(|(workspace_id, _)| workspace_id.clone());
9873 if matched.is_some() && workspaces.len() == 1 {
9874 return Err(failure(
9875 NodeFailureCode::LastWorkspace,
9876 "node must retain at least one workspace",
9877 ));
9878 }
9879 matched
9880 };
9881 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9882 let bindings = self
9883 .session_bindings
9884 .lock()
9885 .unwrap_or_else(|poisoned| poisoned.into_inner());
9886 if bindings
9887 .values()
9888 .any(|binding| &binding.workspace_id == workspace_id)
9889 {
9890 return Err(failure(
9891 NodeFailureCode::WorkspaceBusy,
9892 "worktree workspace retains a bound session; remove every session first",
9893 ));
9894 }
9895 }
9896 remove_git_worktree(&source_root, &target.path)
9897 .await
9898 .map_err(git_worktree_failure)?;
9899 if let Some(workspace_id) = registered_workspace_id.as_ref() {
9900 self.unregister_workspace(workspace_id)
9901 .map_err(|error| failure(error.code, &error.message))?;
9902 }
9903 Ok(registered_workspace_id)
9904 }
9905
9906 async fn cleanup_managed_worktree(
9907 &self,
9908 lease_id: &ManagedWorktreeLeaseId,
9909 explicit: bool,
9910 ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
9911 let lease = self.managed_worktrees.lock()
9912 .unwrap_or_else(|poisoned| poisoned.into_inner())
9913 .get(lease_id)
9914 .cloned()
9915 .ok_or_else(|| failure(
9916 NodeFailureCode::UnknownManagedWorktreeLease,
9917 "managed worktree lease does not exist",
9918 ))?;
9919 if lease.state == ManagedWorktreeLeaseState::Removed {
9920 return Ok(lease.snapshot());
9921 }
9922 if lease.has_holders() {
9923 return Err(failure(
9924 NodeFailureCode::ManagedWorktreeBusy,
9925 "managed worktree lease still has a session or durable-record holder",
9926 ));
9927 }
9928 if self
9929 .materializations
9930 .lock()
9931 .unwrap_or_else(|poisoned| poisoned.into_inner())
9932 .values()
9933 .any(|record| record.managed_lease_id() == Some(lease_id))
9934 {
9935 return Err(failure(
9936 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9937 "managed worktree cleanup is blocked by session environment ownership",
9938 ));
9939 }
9940 if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
9941 let snapshot = {
9942 let mut registry = self.managed_worktrees.lock()
9943 .unwrap_or_else(|poisoned| poisoned.into_inner());
9944 let current = registry.get_mut(lease_id)
9945 .expect("managed worktree lease remains present");
9946 current.state = ManagedWorktreeLeaseState::Retained;
9947 current.cleanup_failure = None;
9948 current.updated_at_unix_ms = unix_time_ms();
9949 current.snapshot()
9950 };
9951 if self.persist_state().is_err() {
9952 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
9953 }
9954 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
9955 return Ok(snapshot);
9956 }
9957 let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
9958 if profile.revision() != &lease.profile_revision {
9959 self.mark_managed_recovery_required(
9960 lease_id,
9961 ManagedWorktreeCleanupFailure::OwnershipConflict,
9962 );
9963 return Err(failure(
9964 NodeFailureCode::ManagedWorktreeRecoveryRequired,
9965 "managed worktree profile revision changed",
9966 ));
9967 }
9968 profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
9969 self.mark_managed_recovery_required(
9970 lease_id,
9971 ManagedWorktreeCleanupFailure::OwnershipConflict,
9972 );
9973 failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
9974 })?;
9975 let source_root = self.workspace_root(&lease.source_workspace_id)?;
9976 let listed = list_git_worktrees(&source_root).await.map_err(|error| {
9977 self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
9978 managed_git_worktree_failure(error)
9979 })?;
9980 let target = listed.iter()
9981 .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
9982 if let Some(target) = target {
9983 if target.is_main || target.is_bare || !exact_owned_worktree(
9984 &lease,
9985 &target.path,
9986 target.branch.as_deref(),
9987 &target.head,
9988 ) {
9989 self.mark_managed_recovery_required(
9990 lease_id,
9991 ManagedWorktreeCleanupFailure::OwnershipConflict,
9992 );
9993 return Err(failure(
9994 NodeFailureCode::ManagedWorktreeOwnershipConflict,
9995 "Git worktree identity no longer matches the managed lease",
9996 ));
9997 }
9998 if target.prunable {
9999 self.mark_managed_cleanup_blocked(
10000 lease_id,
10001 ManagedWorktreeCleanupFailure::Prunable,
10002 );
10003 return Err(failure(
10004 NodeFailureCode::ManagedWorktreeRecoveryRequired,
10005 "managed worktree is prunable and requires recovery",
10006 ));
10007 }
10008 if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10009 let failure_kind = match error.kind {
10010 GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10011 GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10012 GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10013 GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10014 GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10015 | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10016 };
10017 if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10018 self.mark_managed_recovery_required(lease_id, failure_kind);
10019 } else {
10020 self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10021 }
10022 return Err(managed_git_worktree_failure(error));
10023 }
10024 } else {
10025 match std::fs::symlink_metadata(&lease.target_root) {
10026 Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10027 Ok(_) => {
10028 self.mark_managed_recovery_required(
10029 lease_id,
10030 ManagedWorktreeCleanupFailure::OwnershipConflict,
10031 );
10032 return Err(failure(
10033 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10034 "managed target exists but is not owned by Git",
10035 ));
10036 }
10037 Err(_) => {
10038 self.mark_managed_cleanup_blocked(
10039 lease_id,
10040 ManagedWorktreeCleanupFailure::Backend,
10041 );
10042 return Err(failure(
10043 NodeFailureCode::BackendOperationFailed,
10044 "managed target identity could not be inspected",
10045 ));
10046 }
10047 }
10048 }
10049 let registered = self.workspaces.read()
10050 .unwrap_or_else(|poisoned| poisoned.into_inner())
10051 .contains_key(&lease.workspace_id);
10052 if registered {
10053 self.unregister_workspace(&lease.workspace_id)?;
10054 }
10055 let removed = {
10056 self.managed_worktrees.lock()
10057 .unwrap_or_else(|poisoned| poisoned.into_inner())
10058 .tombstone(lease_id, unix_time_ms())
10059 .expect("managed worktree lease remains present during cleanup")
10060 };
10061 self.persist_state().map_err(persistence_failure)?;
10062 self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10063 Ok(removed.snapshot())
10064 }
10065
10066 fn mark_managed_cleanup_blocked(
10067 &self,
10068 lease_id: &ManagedWorktreeLeaseId,
10069 failure_kind: ManagedWorktreeCleanupFailure,
10070 ) {
10071 let snapshot = {
10072 let mut registry = self.managed_worktrees.lock()
10073 .unwrap_or_else(|poisoned| poisoned.into_inner());
10074 let Some(lease) = registry.get_mut(lease_id) else { return };
10075 lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10076 lease.cleanup_failure = Some(failure_kind);
10077 lease.updated_at_unix_ms = unix_time_ms();
10078 lease.snapshot()
10079 };
10080 let _ = self.persist_state();
10081 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10082 }
10083
10084 fn require_worktree_service(
10085 &self,
10086 source_workspace_id: &WorkspaceId,
10087 ) -> Result<(), NodeFailure> {
10088 if self.managed_worktrees.lock()
10089 .unwrap_or_else(|poisoned| poisoned.into_inner())
10090 .lease_for_workspace(source_workspace_id)
10091 .is_some()
10092 {
10093 return Err(failure(
10094 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10095 "managed worktree targets are internal-only workspaces",
10096 ));
10097 }
10098 match self
10099 .worktree_service_modes
10100 .get(source_workspace_id)
10101 .copied()
10102 .unwrap_or(WorktreeServiceMode::Manual)
10103 {
10104 WorktreeServiceMode::Manual => Ok(()),
10105 WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10106 NodeFailureCode::UnsupportedSpawnCapability,
10107 "Git worktree service mode is unavailable in this node build",
10108 )),
10109 }
10110 }
10111
10112 fn managed_reservation_conflict(
10113 &self,
10114 workspace_id: Option<&WorkspaceId>,
10115 root: Option<&str>,
10116 ) -> bool {
10117 self.managed_worktrees.lock()
10118 .unwrap_or_else(|poisoned| poisoned.into_inner())
10119 .active_records()
10120 .any(|lease| {
10121 workspace_id.is_some_and(|workspace_id| {
10122 workspace_id == &lease.source_workspace_id
10123 || workspace_id == &lease.workspace_id
10124 }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10125 })
10126 }
10127
10128 fn reject_managed_reservation(
10129 &self,
10130 workspace_id: Option<&WorkspaceId>,
10131 root: Option<&str>,
10132 ) -> Result<(), NodeFailure> {
10133 if self.managed_reservation_conflict(workspace_id, root) {
10134 Err(failure(
10135 NodeFailureCode::ManagedWorktreeOwnershipConflict,
10136 "managed worktree ownership may only be mutated through its lease cleanup",
10137 ))
10138 } else {
10139 Ok(())
10140 }
10141 }
10142
10143 fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10144 self.validate_address(address)?;
10145 self.workspace_root(&address.workspace_id)
10146 }
10147
10148 fn require_session_runtime_policy(
10182 &self,
10183 address: &SessionAddress,
10184 requirement: ProviderRuntimeRequirement,
10185 ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10186 let runtime_policy = {
10187 let bindings = self
10188 .session_bindings
10189 .lock()
10190 .unwrap_or_else(|poisoned| poisoned.into_inner());
10191 let binding = bindings
10192 .get(&address.session.instance_id)
10193 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10194 if binding.workspace_id != address.workspace_id {
10195 return Err(failure(
10196 NodeFailureCode::SessionWorkspaceMismatch,
10197 "session belongs to another workspace",
10198 ));
10199 }
10200 if binding.generation != address.session.generation {
10201 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10202 }
10203 binding.runtime_policy
10204 };
10205 if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10206 let snapshot = self.internal_session_snapshot(address)?;
10207 if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10208 && snapshot.provider.lead_activity != ProviderActivity::Idle
10209 {
10210 return Err(failure(
10211 NodeFailureCode::TurnInFlight,
10212 "session already has a turn in flight; wait for it to complete before sending another prompt",
10213 ));
10214 }
10215 }
10216 require_policy(runtime_policy, false, false, requirement)
10217 .map(|()| runtime_policy)
10218 .map_err(|_| failure(
10219 NodeFailureCode::UnsupportedCapability,
10220 "session runtime policy does not admit this semantic operation",
10221 ))
10222 }
10223
10224 fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10225 let mut bindings = self
10226 .session_bindings
10227 .lock()
10228 .unwrap_or_else(|poisoned| poisoned.into_inner());
10229 if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10230 binding.workspace_id == address.workspace_id
10231 && binding.generation == address.session.generation
10232 }) {
10233 let removed = bindings.remove(&address.session.instance_id);
10234 self.clear_terminal_frame_watermark(address);
10235 SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10236 return removed;
10237 }
10238 None
10239 }
10240
10241 fn cleanup_session_owned_materialization(
10242 &self,
10243 address: &SessionAddress,
10244 binding: &SessionBinding,
10245 ) -> Result<(), NodeFailure> {
10246 let Some(id) = binding.materialization_id.as_ref() else {
10247 return Ok(());
10248 };
10249 let owner = self
10250 .materializations
10251 .lock()
10252 .unwrap_or_else(|poisoned| poisoned.into_inner())
10253 .get(id)
10254 .map(|record| record.owner().clone());
10255 match owner {
10256 None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10257 Some(MaterializationOwner::Session {
10258 incarnation_id,
10259 instance_id,
10260 generation,
10261 }) if incarnation_id == self.incarnation_id
10262 && instance_id == address.session.instance_id
10263 && generation == address.session.generation =>
10264 {
10265 self.cleanup_materialization(id)
10266 }
10267 Some(MaterializationOwner::Session { .. }) => Err(failure(
10268 NodeFailureCode::BackendOperationFailed,
10269 "session environment ownership requires recovery",
10270 )),
10271 }
10272 }
10273
10274 fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10275 let mut watermarks = self
10276 .terminal_frame_watermarks
10277 .lock()
10278 .unwrap_or_else(|poisoned| poisoned.into_inner());
10279 if watermarks
10280 .get(&address.session.instance_id)
10281 .is_some_and(|(current, _)| current == address)
10282 {
10283 watermarks.remove(&address.session.instance_id);
10284 }
10285 }
10286
10287 #[cfg(test)]
10288 fn address_for(
10289 &self,
10290 instance_id: AgentInstanceId,
10291 generation: SessionGeneration,
10292 ) -> Option<SessionAddress> {
10293 let binding = self.session_bindings
10294 .lock()
10295 .unwrap_or_else(|poisoned| poisoned.into_inner())
10296 .get(&instance_id)
10297 .cloned()?;
10298 (binding.generation == generation).then_some(SessionAddress {
10299 workspace_id: binding.workspace_id,
10300 session: SessionKey { instance_id, generation },
10301 })
10302 }
10303
10304 fn arm_resume(
10305 &self,
10306 address: &SessionAddress,
10307 command_id: CommandId,
10308 runtime_policy: ProviderRuntimePolicy,
10309 ) -> Result<(), NodeFailure> {
10310 let mut bindings = self
10311 .session_bindings
10312 .lock()
10313 .unwrap_or_else(|poisoned| poisoned.into_inner());
10314 let binding = bindings
10315 .get_mut(&address.session.instance_id)
10316 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10317 if binding.workspace_id != address.workspace_id {
10318 return Err(failure(
10319 NodeFailureCode::SessionWorkspaceMismatch,
10320 "session belongs to another workspace",
10321 ));
10322 }
10323 if binding.generation != address.session.generation {
10324 return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10325 }
10326 binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10327 Ok(())
10328 }
10329
10330 fn clear_armed_resume(&self, address: &SessionAddress) {
10331 let mut bindings = self
10332 .session_bindings
10333 .lock()
10334 .unwrap_or_else(|poisoned| poisoned.into_inner());
10335 if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10336 if binding.workspace_id == address.workspace_id
10337 && binding.generation == address.session.generation
10338 {
10339 binding.pending_resume = None;
10340 }
10341 }
10342 }
10343
10344 fn publish_control(&self, event: ControlEvent) {
10345 let revokes_harness_mcp = matches!(
10346 &event.event,
10347 ControlEventKind::Exited { .. }
10348 | ControlEventKind::Failed { .. }
10349 | ControlEventKind::Removed
10350 );
10351 let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10352 let mut bindings = self
10353 .session_bindings
10354 .lock()
10355 .unwrap_or_else(|poisoned| poisoned.into_inner());
10356 let Some(binding) = bindings.get_mut(&event.instance_id) else {
10357 return;
10358 };
10359 let previous_address = SessionAddress {
10360 workspace_id: binding.workspace_id.clone(),
10361 session: SessionKey {
10362 instance_id: event.instance_id,
10363 generation: binding.generation,
10364 },
10365 };
10366 let mut resume_command_rejected = false;
10367 if binding.generation == event.generation {
10368 if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10369 let rejected_resume = matches!(
10370 &event.event,
10371 ControlEventKind::CommandRejected { .. }
10372 ) && event.command_id == Some(pending_command_id);
10373 let resume_failed = matches!(
10374 &event.event,
10375 ControlEventKind::ResumeDenied { .. }
10376 | ControlEventKind::ResumeFailed { .. }
10377 );
10378 if pending_generation == binding.generation
10379 && (rejected_resume || resume_failed)
10380 {
10381 resume_command_rejected = rejected_resume;
10382 binding.pending_resume = None;
10383 }
10384 }
10385 } else {
10386 let expected = binding.generation.0.checked_add(1);
10387 let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10388 if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10389 generation == binding.generation
10390 })
10391 || expected != Some(event.generation.0)
10392 || !authorized
10393 {
10394 return;
10395 }
10396 let (_, _, runtime_policy) = binding
10397 .pending_resume
10398 .expect("authorized resume retains its pending runtime policy");
10399 binding.generation = event.generation;
10400 binding.runtime_policy = runtime_policy;
10401 binding.pending_resume = None;
10402 }
10403 (
10404 SessionAddress {
10405 workspace_id: binding.workspace_id.clone(),
10406 session: SessionKey {
10407 instance_id: event.instance_id,
10408 generation: event.generation,
10409 },
10410 },
10411 (previous_address.session.generation != event.generation)
10412 .then_some(previous_address),
10413 binding.record_id.clone(),
10414 binding.runtime_policy.provider_session_identity,
10415 resume_command_rejected,
10416 )
10417 };
10418 if let Some(previous_address) = previous_address.as_ref() {
10419 self.clear_terminal_frame_watermark(previous_address);
10420 }
10421 if let Some(record_id) = record_id {
10422 self.reconcile_managed_record(
10423 &record_id,
10424 &address,
10425 &event,
10426 identity_admitted,
10427 resume_command_rejected,
10428 );
10429 }
10430 match &event.event {
10438 ControlEventKind::Running { process_id } => {
10439 #[cfg(unix)]
10440 if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10441 proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10442 }
10443 tracing::info!(
10444 node_id = %self.node_id,
10445 workspace_id = %address.workspace_id,
10446 session = ?address.session,
10447 pid = ?process_id,
10448 "session process is running",
10449 );
10450 }
10451 ControlEventKind::Exited { exit_code, forced } => {
10452 #[cfg(unix)]
10453 if let Some(proxy) = self.signer_proxy.get().cloned() {
10454 let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10455 if proxy.has_session(&key) {
10456 std::thread::spawn(move || proxy.release(&key));
10458 }
10459 }
10460 tracing::info!(
10461 node_id = %self.node_id,
10462 workspace_id = %address.workspace_id,
10463 session = ?address.session,
10464 exit_code = ?exit_code,
10465 forced = forced,
10466 "session process exited",
10467 );
10468 }
10469 ControlEventKind::Failed { message } => {
10470 tracing::warn!(
10471 node_id = %self.node_id,
10472 workspace_id = %address.workspace_id,
10473 session = ?address.session,
10474 cause = %message,
10475 "session failed",
10476 );
10477 }
10478 ControlEventKind::CommandRejected { message } => {
10479 tracing::warn!(
10480 node_id = %self.node_id,
10481 workspace_id = %address.workspace_id,
10482 session = ?address.session,
10483 cause = %message,
10484 "session command rejected",
10485 );
10486 }
10487 ControlEventKind::Removed => {
10488 tracing::info!(
10489 node_id = %self.node_id,
10490 workspace_id = %address.workspace_id,
10491 session = ?address.session,
10492 "session removed",
10493 );
10494 }
10495 ControlEventKind::ResumeAuthorized { session: provider_session } => {
10496 tracing::info!(
10497 node_id = %self.node_id,
10498 workspace_id = %address.workspace_id,
10499 session = ?address.session,
10500 provider_session = ?provider_session,
10501 "session resume authorized",
10502 );
10503 }
10504 ControlEventKind::Resumed { session: provider_session, process_id } => {
10505 tracing::info!(
10506 node_id = %self.node_id,
10507 workspace_id = %address.workspace_id,
10508 session = ?address.session,
10509 provider_session = ?provider_session,
10510 pid = ?process_id,
10511 "session resumed",
10512 );
10513 }
10514 ControlEventKind::ResumeDenied { reason } => {
10515 tracing::warn!(
10516 node_id = %self.node_id,
10517 workspace_id = %address.workspace_id,
10518 session = ?address.session,
10519 cause = %reason,
10520 "session resume denied",
10521 );
10522 }
10523 ControlEventKind::ResumeFailed { message } => {
10524 tracing::warn!(
10525 node_id = %self.node_id,
10526 workspace_id = %address.workspace_id,
10527 session = ?address.session,
10528 cause = %message,
10529 "session resume failed",
10530 );
10531 }
10532 _ => {}
10533 }
10534 self.apply_pending_acp_options(&address, &event);
10535 let stream_chunk = agent_stream_chunk(&event);
10536 self.publish(NodeEvent::Control {
10537 address: address.clone(),
10538 event,
10539 });
10540 if let Some(chunk) = stream_chunk {
10541 self.publish_agent_stream_chunk(address.clone(), chunk);
10542 }
10543 if revokes_harness_mcp {
10544 SessionLifecycleHook::on_session_ended(self, &address);
10545 }
10546 }
10547
10548 fn reconcile_managed_record(
10549 &self,
10550 record_id: &SessionRecordId,
10551 address: &SessionAddress,
10552 event: &ControlEvent,
10553 identity_admitted: bool,
10554 resume_command_rejected: bool,
10555 ) {
10556 let _transaction = self
10557 .state_transaction
10558 .lock()
10559 .unwrap_or_else(|poisoned| poisoned.into_inner());
10560 let observed_identity = match &event.event {
10561 ControlEventKind::ProviderEvent {
10562 event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10563 ..
10564 } if identity_admitted => Some(identity.clone()),
10565 _ => None,
10566 };
10567 let identity_was_observed = observed_identity.is_some();
10568 let replacement_id = observed_identity
10569 .as_ref()
10570 .and_then(|_| self.allocate_record_id().ok());
10571 let mut upserts = Vec::new();
10572 let mut removals = Vec::new();
10573 let mut rebind = None;
10574 let original_records;
10575 let original_materializations = self
10576 .materializations
10577 .lock()
10578 .unwrap_or_else(|poisoned| poisoned.into_inner())
10579 .clone();
10580 {
10581 let mut records = self
10582 .session_records
10583 .lock()
10584 .unwrap_or_else(|poisoned| poisoned.into_inner());
10585 original_records = records.records.clone();
10586 let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10587 return;
10588 };
10589 if let Some(identity) = observed_identity {
10590 let existing_id = records
10591 .records
10592 .values()
10593 .find(|record| {
10594 record.record_id != *record_id
10595 && record.provider_session.as_ref().is_some_and(|record_identity| {
10596 session_registry::same_provider_session(
10597 &record.provider,
10598 record_identity,
10599 ¤t_snapshot.provider,
10600 &identity,
10601 )
10602 })
10603 })
10604 .map(|record| record.record_id.clone());
10605 if let Some(existing_id) = existing_id {
10606 let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10607 record.mode == current_snapshot.mode
10608 && record.workspace_id == current_snapshot.workspace_id
10609 && record.environment_profile == current_snapshot.environment_profile
10610 && record.bundle == current_snapshot.bundle
10611 && record.context_id == current_snapshot.context_id
10612 && record.context == current_snapshot.context
10613 && record
10614 .canonical_root
10615 .as_utf8()
10616 .zip(current_snapshot.canonical_root.as_utf8())
10617 .is_some_and(|(left, right)| platform::roots_equal(left, right))
10618 });
10619 if !scope_matches {
10620 if let Some(current) = records.records.get_mut(record_id) {
10621 current.state = ManagedSessionState::Unavailable;
10622 current.last_error =
10623 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10624 current.updated_at_unix_ms = unix_time_ms();
10625 upserts.push(current.clone());
10626 }
10627 } else {
10628 let existing_is_active = records
10629 .records
10630 .get(&existing_id)
10631 .is_some_and(|record| record.active_session.is_some());
10632 if existing_is_active {
10633 if let Some(current) = records.records.get_mut(record_id) {
10634 current.state = ManagedSessionState::Unavailable;
10635 current.last_error =
10636 Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
10637 current.updated_at_unix_ms = unix_time_ms();
10638 upserts.push(current.clone());
10639 }
10640 } else {
10641 if current_snapshot.provider_session.is_some() {
10642 if let Some(current) = records.records.get_mut(record_id) {
10643 current.active_session = None;
10644 current.state = ManagedSessionState::Dormant;
10645 current.updated_at_unix_ms = unix_time_ms();
10646 upserts.push(current.clone());
10647 }
10648 } else {
10649 records.records.remove(record_id);
10650 removals.push(record_id.clone());
10651 }
10652 if let Some(existing) = records.records.get_mut(&existing_id) {
10653 existing.provider_session = Some(identity.clone());
10654 existing.active_session = Some(address.clone());
10655 existing.state = ManagedSessionState::Live;
10656 existing.updated_at_unix_ms = unix_time_ms();
10657 existing.last_error = None;
10658 upserts.push(existing.clone());
10659 }
10660 rebind = Some(existing_id);
10661 }
10662 }
10663 } else if current_snapshot
10664 .provider_session
10665 .as_ref()
10666 .is_some_and(|current| {
10667 !session_registry::same_provider_session(
10668 ¤t_snapshot.provider,
10669 current,
10670 ¤t_snapshot.provider,
10671 &identity,
10672 )
10673 })
10674 {
10675 if let Some(current) = records.records.get_mut(record_id) {
10676 current.active_session = None;
10677 if current.bundle.is_some() || current.context.is_some() {
10678 let had_bundle = current.bundle.is_some();
10679 current.provider_session = None;
10680 current.bundle = None;
10681 current.context_id = None;
10682 current.context = None;
10683 current.state = ManagedSessionState::Unavailable;
10684 current.last_error = Some(if had_bundle {
10685 "bundle-unavailable".to_owned()
10686 } else {
10687 "context-unavailable".to_owned()
10688 });
10689 } else {
10690 current.state = ManagedSessionState::Dormant;
10691 }
10692 current.updated_at_unix_ms = unix_time_ms();
10693 upserts.push(current.clone());
10694 }
10695 if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
10696 if let Some(current) = records.records.get_mut(record_id) {
10697 current.state = ManagedSessionState::Unavailable;
10698 current.last_error =
10699 Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
10700 current.updated_at_unix_ms = unix_time_ms();
10701 upserts.push(current.clone());
10702 }
10703 } else if let Some(new_record_id) = replacement_id {
10704 let now = unix_time_ms();
10705 let replacement = ManagedSessionRecord {
10706 record_id: new_record_id.clone(),
10707 display_name: format!(
10708 "{} #{}",
10709 current_snapshot.provider.as_str(),
10710 records
10711 .records
10712 .values()
10713 .filter(|record| {
10714 record.provider == current_snapshot.provider
10715 })
10716 .count()
10717 .saturating_add(1),
10718 ),
10719 provider: current_snapshot.provider.clone(),
10720 mode: current_snapshot.mode,
10721 state: ManagedSessionState::Live,
10722 workspace_id: current_snapshot.workspace_id,
10723 canonical_root: current_snapshot.canonical_root,
10724 provider_session: Some(identity),
10725 active_session: Some(address.clone()),
10726 environment_profile: current_snapshot.environment_profile,
10727 bundle: current_snapshot.bundle,
10728 context_id: current_snapshot.context_id,
10729 context: current_snapshot.context,
10730 exported_context: None,
10736 task_binding: current_snapshot.task_binding,
10737 created_at_unix_ms: now,
10738 updated_at_unix_ms: now,
10739 last_error: None,
10740 };
10741 records
10742 .records
10743 .insert(new_record_id.clone(), replacement.clone());
10744 upserts.push(replacement);
10745 rebind = Some(new_record_id);
10746 } else if let Some(current) = records.records.get_mut(record_id) {
10747 current.state = ManagedSessionState::Unavailable;
10748 current.last_error =
10749 Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
10750 upserts.push(current.clone());
10751 }
10752 } else if let Some(current) = records.records.get_mut(record_id) {
10753 current.provider_session = Some(identity);
10754 current.active_session = Some(address.clone());
10755 current.state = ManagedSessionState::Live;
10756 current.updated_at_unix_ms = unix_time_ms();
10757 current.last_error = None;
10758 upserts.push(current.clone());
10759 }
10760 } else if let Some(current) = records.records.get_mut(record_id) {
10761 if resume_command_rejected {
10762 current.active_session = None;
10763 current.state = detached_record_state(current);
10764 current.updated_at_unix_ms = unix_time_ms();
10765 current.last_error =
10766 Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
10767 upserts.push(current.clone());
10768 } else {
10769 match &event.event {
10770 ControlEventKind::Running { .. }
10771 | ControlEventKind::ResumeAuthorized { .. }
10772 | ControlEventKind::Resumed { .. } => {
10773 current.active_session = Some(address.clone());
10774 current.state = active_record_state(
10775 identity_admitted,
10776 current.provider_session.is_some(),
10777 );
10778 current.updated_at_unix_ms = unix_time_ms();
10779 current.last_error = None;
10780 upserts.push(current.clone());
10781 }
10782 ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
10783 current.active_session = None;
10784 current.state = detached_record_state(current);
10785 current.updated_at_unix_ms = unix_time_ms();
10786 upserts.push(current.clone());
10787 }
10788 ControlEventKind::Failed { message }
10789 | ControlEventKind::ResumeFailed { message } => {
10790 current.active_session = None;
10791 current.state = detached_record_state(current);
10792 current.updated_at_unix_ms = unix_time_ms();
10793 current.last_error = Some(
10794 session_registry::sanitized_record_error_summary(message),
10795 );
10796 upserts.push(current.clone());
10797 }
10798 ControlEventKind::ResumeDenied { reason } => {
10799 current.active_session = None;
10800 current.state = detached_record_state(current);
10801 current.updated_at_unix_ms = unix_time_ms();
10802 current.last_error = Some(
10803 session_registry::sanitized_record_error_summary(reason),
10804 );
10805 upserts.push(current.clone());
10806 }
10807 _ => {}
10808 }
10809 }
10810 }
10811 }
10812 let materialization_owner_record = identity_was_observed.then(|| {
10813 rebind.clone().unwrap_or_else(|| record_id.clone())
10814 }).and_then(|candidate| {
10815 self.session_records
10816 .lock()
10817 .unwrap_or_else(|poisoned| poisoned.into_inner())
10818 .records
10819 .get(&candidate)
10820 .filter(|record| {
10821 record.provider_session.is_some()
10822 && record.active_session.as_ref() == Some(address)
10823 })
10824 .map(|_| candidate)
10825 });
10826 if let Some(owner_record_id) = materialization_owner_record {
10827 let materialization_id = self
10828 .session_bindings
10829 .lock()
10830 .unwrap_or_else(|poisoned| poisoned.into_inner())
10831 .get(&address.session.instance_id)
10832 .and_then(|binding| binding.materialization_id.clone());
10833 if let Some(materialization_id) = materialization_id {
10834 let mut transfer_failed = false;
10835 {
10836 let mut materializations = self
10837 .materializations
10838 .lock()
10839 .unwrap_or_else(|poisoned| poisoned.into_inner());
10840 if let Some(ownership) = materializations.get_mut(&materialization_id) {
10841 match ownership.owner() {
10842 MaterializationOwner::Session {
10843 incarnation_id,
10844 instance_id,
10845 generation,
10846 } if *incarnation_id == self.incarnation_id
10847 && *instance_id == address.session.instance_id
10848 && *generation == address.session.generation =>
10849 {
10850 transfer_failed = ownership
10851 .transfer_to_record(owner_record_id.clone(), unix_time_ms())
10852 .is_err();
10853 }
10854 MaterializationOwner::Record { record_id }
10855 if record_id == &owner_record_id => {}
10856 MaterializationOwner::Record { record_id } => {
10857 let expected = record_id.clone();
10858 transfer_failed = ownership
10859 .transfer_record_owner(
10860 &expected,
10861 owner_record_id.clone(),
10862 unix_time_ms(),
10863 )
10864 .is_err();
10865 }
10866 _ => transfer_failed = true,
10867 }
10868 if transfer_failed {
10869 let _ = ownership.mark_recovery_required(unix_time_ms());
10870 }
10871 } else {
10872 transfer_failed = true;
10873 }
10874 }
10875 if transfer_failed {
10876 if let Some(record) = self
10877 .session_records
10878 .lock()
10879 .unwrap_or_else(|poisoned| poisoned.into_inner())
10880 .records
10881 .get_mut(&owner_record_id)
10882 {
10883 record.state = ManagedSessionState::Unavailable;
10884 record.last_error = Some("environment-profile-unavailable".to_owned());
10885 record.updated_at_unix_ms = unix_time_ms();
10886 upserts.push(record.clone());
10887 }
10888 }
10889 }
10890 }
10891 if let Some(new_record_id) = rebind.clone() {
10892 if let Some(binding) = self
10893 .session_bindings
10894 .lock()
10895 .unwrap_or_else(|poisoned| poisoned.into_inner())
10896 .get_mut(&address.session.instance_id)
10897 {
10898 binding.record_id = Some(new_record_id);
10899 }
10900 }
10901 if upserts.is_empty() && removals.is_empty() {
10902 return;
10903 }
10904 if self.persist_state_locked().is_err() {
10905 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10906 let mut records = self
10907 .session_records
10908 .lock()
10909 .unwrap_or_else(|poisoned| poisoned.into_inner());
10910 records.records = original_records;
10911 *self
10912 .materializations
10913 .lock()
10914 .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
10915 if let Some(current) = records.records.get_mut(record_id) {
10916 current.state = ManagedSessionState::Unavailable;
10917 current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
10918 current.updated_at_unix_ms = unix_time_ms();
10919 upserts = vec![current.clone()];
10920 } else {
10921 upserts.clear();
10922 }
10923 removals.clear();
10924 drop(records);
10925 if let Some(binding) = self
10926 .session_bindings
10927 .lock()
10928 .unwrap_or_else(|poisoned| poisoned.into_inner())
10929 .get_mut(&address.session.instance_id)
10930 {
10931 binding.record_id = Some(record_id.clone());
10932 }
10933 eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
10934 }
10935 for record in upserts {
10936 self.publish_record(record);
10937 }
10938 for record_id in removals {
10939 self.publish(NodeEvent::SessionRecordRemoved { record_id });
10940 }
10941 }
10942
10943 fn snapshot(&self) -> NodeSnapshot {
10944 let control = self.handle.snapshot();
10945 let bindings = self
10946 .session_bindings
10947 .lock()
10948 .unwrap_or_else(|poisoned| poisoned.into_inner())
10949 .clone();
10950 let workspace_roots = self
10951 .workspaces
10952 .read()
10953 .unwrap_or_else(|poisoned| poisoned.into_inner())
10954 .clone();
10955 let environment_profiles = self
10956 .environment_profiles
10957 .read()
10958 .unwrap_or_else(|poisoned| poisoned.into_inner());
10959 let mut workspaces = workspace_roots
10960 .iter()
10961 .map(|(workspace_id, canonical_root)| {
10962 (
10963 workspace_id.clone(),
10964 WorkspaceSnapshot {
10965 workspace_id: workspace_id.clone(),
10966 canonical_root: opaque_windows_path(canonical_root.clone()),
10967 sessions: Vec::new(),
10968 worktree_service_mode: Some(
10969 self.worktree_service_modes
10970 .get(workspace_id)
10971 .copied()
10972 .unwrap_or(WorktreeServiceMode::Manual),
10973 ),
10974 managed_worktree_profiles: Some(WorktreeProfileInventory {
10975 profiles: self
10976 .managed_worktree_profiles
10977 .get(workspace_id)
10978 .into_iter()
10979 .flat_map(BTreeMap::values)
10980 .map(|profile| ManagedWorktreeProfileSummary {
10981 id: profile.profile_id().clone(),
10982 revision: profile.revision().clone(),
10983 retention: profile.retention(),
10984 })
10985 .collect(),
10986 }),
10987 },
10988 )
10989 })
10990 .collect::<BTreeMap<_, _>>();
10991 let mut agent_progress = Vec::new();
10992 for session in &control.sessions {
10993 let Some(binding) = bindings.get(&session.instance_id) else {
10994 continue;
10995 };
10996 if binding.generation != session.generation {
10997 continue;
10998 }
10999 if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11000 let address = SessionAddress {
11001 workspace_id: binding.workspace_id.clone(),
11002 session: SessionKey {
11003 instance_id: session.instance_id,
11004 generation: session.generation,
11005 },
11006 };
11007 if let Some(progress) =
11008 agent_progress_from_provider_snapshot(address, &session.provider)
11009 {
11010 agent_progress.push(progress);
11011 }
11012 workspace.sessions.push(session.clone());
11013 }
11014 }
11015 NodeSnapshot {
11016 node_id: self.node_id.clone(),
11017 enabled_providers: self.enabled_providers.clone(),
11018 provider_runtime_statuses: {
11019 let updates = self
11020 .provider_runtime_status_updates
11021 .read()
11022 .unwrap_or_else(|poisoned| poisoned.into_inner());
11023 ProviderRuntimeStatuses::new(
11024 self.provider_runtime_statuses
11025 .iter()
11026 .filter(|status| !updates.contains_key(status.provider()))
11027 .cloned()
11028 .chain(updates.values().cloned()),
11029 )
11030 .expect("refreshed provider runtime inventory remains bounded and unique")
11031 },
11032 workspaces: workspaces.into_values().collect(),
11033 session_records: self
11034 .session_records
11035 .lock()
11036 .unwrap_or_else(|poisoned| poisoned.into_inner())
11037 .records
11038 .values()
11039 .cloned()
11040 .collect(),
11041 managed_worktrees: self.managed_worktrees
11042 .lock()
11043 .unwrap_or_else(|poisoned| poisoned.into_inner())
11044 .snapshots(),
11045 launch_inventory: Some(LaunchInventory {
11046 spawn_profiles: Some(
11047 self.spawn_profiles
11048 .iter()
11049 .filter_map(|profile| {
11050 resolved_spawn_profile_summary(profile, &environment_profiles)
11051 })
11052 .collect(),
11053 ),
11054 bundles: Some(
11055 self.bundle_catalog
11056 .read()
11057 .unwrap_or_else(|poisoned| poisoned.into_inner())
11058 .iter()
11059 .map(NodeBundle::receipt)
11060 .collect(),
11061 ),
11062 network_allowlists: {
11065 let catalog = self
11066 .network_allowlist_catalog
11067 .read()
11068 .unwrap_or_else(|poisoned| poisoned.into_inner());
11069 if catalog.is_empty() {
11070 None
11071 } else {
11072 Some(catalog.ids().cloned().collect())
11073 }
11074 },
11075 }),
11076 agent_progress,
11077 }
11078 }
11079
11080 fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11081 assert!(
11082 !matches!(
11083 &event,
11084 NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11085 ),
11086 "terminal frame and agent stream events must use their own dedicated live channel",
11087 );
11088 let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11089 let sequence = history
11090 .last_sequence
11091 .checked_add(1)
11092 .expect("node event sequence exhausted");
11093 if history.events.len() == NODE_EVENT_HISTORY_MAX {
11094 if let Some(evicted) = history.events.pop_front() {
11095 history.replay_floor_sequence = evicted
11096 .sequence
11097 .checked_add(1)
11098 .expect("node replay floor sequence exhausted");
11099 history.removed_record_providers.remove(&evicted.sequence);
11100 }
11101 }
11102 match &event {
11103 NodeEvent::HarnessMcpReadCall { .. } => {
11104 panic!("transient harness MCP calls must not enter durable event history")
11105 }
11106 NodeEvent::SessionRecordUpserted { record } => {
11107 history
11108 .record_providers
11109 .insert(record.record_id.clone(), record.provider.clone());
11110 }
11111 NodeEvent::SessionRecordRemoved { record_id } => {
11112 if let Some(provider) = history.record_providers.remove(record_id) {
11113 history.removed_record_providers.insert(sequence, provider);
11114 }
11115 }
11116 NodeEvent::ManagedWorktreeUpserted { .. }
11117 | NodeEvent::ManagedWorktreeRemoved { .. }
11118 | NodeEvent::Control { .. }
11119 | NodeEvent::ControllerChanged { .. }
11120 | NodeEvent::SessionRecordHistorySummarized { .. }
11121 | NodeEvent::WorkspaceAdded { .. }
11122 | NodeEvent::WorkspaceRemoved { .. }
11123 | NodeEvent::ResyncRequired { .. }
11124 | NodeEvent::AgentStream { .. }
11125 | NodeEvent::TerminalFrame { .. } => {}
11126 }
11127 let envelope = NodeEventEnvelope { sequence, event };
11128 history.events.push_back(envelope.clone());
11129 history.last_sequence = sequence;
11130 let _ = self.event_tx.send(envelope.clone());
11131 drop(history);
11132 envelope
11133 }
11134
11135 fn publish_transient(&self, event: NodeEvent) -> bool {
11146 assert!(event.requires_harness_mcp_proxy_capability());
11147 assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11148 let envelope = NodeEventEnvelope { sequence: 0, event };
11149 match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11150 Ok(_) => true,
11151 Err(broadcast::error::SendError(undelivered)) => {
11152 if let Some(NodeEventEnvelope {
11153 event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11154 ..
11155 }) = undelivered.first() {
11156 tracing::warn!(
11162 call_id = call_id.as_str(),
11163 content_type = ?request.content_type,
11164 cause = "no-subscribers",
11165 "harness MCP proxy event could not be published",
11166 );
11167 }
11168 false
11169 }
11170 }
11171 }
11172
11173 fn publish_terminal_frames(&self) {
11174 let snapshot = self.handle.snapshot();
11175 let bindings = self
11176 .session_bindings
11177 .lock()
11178 .unwrap_or_else(|poisoned| poisoned.into_inner());
11179 let candidates = snapshot
11180 .sessions
11181 .iter()
11182 .filter_map(|session| {
11183 let binding = bindings.get(&session.instance_id)?;
11184 if binding.generation != session.generation {
11185 return None;
11186 }
11187 let frame = session.terminal_frame.clone()?;
11188 crate::session_signals::feed(
11189 binding.workspace_id.as_str(),
11190 session.instance_id.0,
11191 session.agent_id.as_str(),
11192 &frame.contents,
11193 );
11194 Some((
11195 SessionAddress {
11196 workspace_id: binding.workspace_id.clone(),
11197 session: SessionKey {
11198 instance_id: session.instance_id,
11199 generation: session.generation,
11200 },
11201 },
11202 frame,
11203 ))
11204 })
11205 .collect();
11206 drop(bindings);
11207 self.publish_terminal_frame_candidates(candidates);
11208 }
11209
11210 fn publish_terminal_frame_candidates(
11211 &self,
11212 candidates: Vec<(SessionAddress, TerminalFrame)>,
11213 ) {
11214 let mut watermarks = self
11215 .terminal_frame_watermarks
11216 .lock()
11217 .unwrap_or_else(|poisoned| poisoned.into_inner());
11218 let mut events = Vec::with_capacity(candidates.len());
11219 for (address, frame) in candidates {
11220 let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11221 let advanced = watermarks
11222 .get(&address.session.instance_id)
11223 .map_or(true, |(current_address, sequence)| {
11224 current_address != &address || frame.sequence > *sequence
11225 });
11226 if !advanced {
11227 continue;
11228 }
11229 if is_first_frame {
11230 if self.controller_state().is_none() {
11244 tracing::warn!(
11245 node_id = %self.node_id,
11246 workspace_id = %address.workspace_id,
11247 session = ?address.session,
11248 terminal_size = ?frame.size,
11249 "session producing output with no controller attached",
11250 );
11251 } else {
11252 tracing::info!(
11253 node_id = %self.node_id,
11254 workspace_id = %address.workspace_id,
11255 session = ?address.session,
11256 terminal_size = ?frame.size,
11257 "session produced its first output",
11258 );
11259 }
11260 }
11261 watermarks.insert(
11262 address.session.instance_id,
11263 (address.clone(), frame.sequence),
11264 );
11265 if let Some((at, reported)) = self
11266 .pty_input_marks
11267 .lock()
11268 .unwrap_or_else(|poisoned| poisoned.into_inner())
11269 .get_mut(&address.session.instance_id)
11270 {
11271 if !*reported && at.elapsed() < Duration::from_secs(5) {
11273 *reported = true;
11274 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");
11275 }
11276 }
11277 events.push(NodeEvent::TerminalFrame { address, frame });
11278 }
11279 drop(watermarks);
11280 if events.is_empty() {
11281 return;
11282 }
11283 let mut history = self
11284 .history
11285 .lock()
11286 .unwrap_or_else(|poisoned| poisoned.into_inner());
11287 let mut envelopes = Vec::with_capacity(events.len());
11288 for event in events {
11289 let sequence = history
11290 .last_sequence
11291 .checked_add(1)
11292 .expect("node event sequence exhausted");
11293 history.last_sequence = sequence;
11294 envelopes.push(NodeEventEnvelope { sequence, event });
11295 }
11296 let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11297 drop(history);
11298 }
11299
11300 fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11309 let mut history = self
11310 .history
11311 .lock()
11312 .unwrap_or_else(|poisoned| poisoned.into_inner());
11313 let sequence = history
11314 .last_sequence
11315 .checked_add(1)
11316 .expect("node event sequence exhausted");
11317 history.last_sequence = sequence;
11318 drop(history);
11319 tracing::debug!(
11327 source_sequence = chunk.source_sequence,
11328 kind = agent_stream_chunk_kind_label(&chunk.kind),
11329 envelope_sequence = sequence,
11330 "publishing agent stream chunk",
11331 );
11332 let envelope = NodeEventEnvelope {
11333 sequence,
11334 event: NodeEvent::AgentStream { address, chunk },
11335 };
11336 let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11337 }
11338
11339 fn current_sequence(&self) -> u64 {
11340 self.history
11341 .lock()
11342 .unwrap_or_else(|poisoned| poisoned.into_inner())
11343 .last_sequence
11344 }
11345
11346 fn controller_state(&self) -> Option<ControllerState> {
11347 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11348 let lease = controller.as_ref().copied()?;
11349 let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11350 if remaining.is_zero() {
11351 *controller = None;
11352 return None;
11353 }
11354 Some(ControllerState {
11355 connection_id: lease.connection_id,
11356 lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11357 })
11358 }
11359
11360 fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11361 if role != ClientRole::Operator {
11362 tracing::warn!(
11363 node_id = %self.node_id,
11364 connection_id,
11365 "controller lease rejected: observer connections cannot acquire control",
11366 );
11367 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11368 }
11369 let lease_ms = if lease_ms == 0 {
11370 DEFAULT_CONTROLLER_LEASE_MS
11371 } else {
11372 lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11373 };
11374 let now = Instant::now();
11375 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11376 if let Some(active) = controller.as_ref() {
11377 if active.connection_id != connection_id && active.expires_at > now {
11378 tracing::warn!(
11379 node_id = %self.node_id,
11380 connection_id,
11381 holder_connection_id = active.connection_id,
11382 "controller lease rejected: another operator holds it",
11383 );
11384 return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11385 }
11386 }
11387 let is_new_attach = match controller.as_ref() {
11394 Some(active) => active.connection_id != connection_id,
11395 None => true,
11396 };
11397 *controller = Some(ControllerLease {
11398 connection_id,
11399 expires_at: now + Duration::from_millis(lease_ms),
11400 });
11401 drop(controller);
11402 let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11403 if is_new_attach {
11404 tracing::info!(
11405 node_id = %self.node_id,
11406 connection_id,
11407 lease_ms,
11408 "controller lease acquired",
11409 );
11410 } else {
11411 tracing::debug!(
11412 node_id = %self.node_id,
11413 connection_id,
11414 lease_ms,
11415 "controller lease renewed",
11416 );
11417 }
11418 self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11419 Ok(state)
11420 }
11421
11422 fn release_controller(&self, connection_id: u64) -> bool {
11423 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11424 let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11425 if released {
11426 *controller = None;
11427 }
11428 drop(controller);
11429 if released {
11430 tracing::info!(
11431 node_id = %self.node_id,
11432 connection_id,
11433 "controller lease released",
11434 );
11435 self.publish(NodeEvent::ControllerChanged { controller: None });
11436 }
11437 released
11438 }
11439
11440 fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11449 let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11450 return;
11451 };
11452 let id = address.session.instance_id;
11453 let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11454 return;
11455 };
11456 let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11457 .iter()
11458 .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11459 .collect();
11460 let (writes, set_model, effort_gap) =
11461 backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11462 if set_model {
11463 if let Some(model_id) = model.clone() {
11464 if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11465 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11466 }
11467 }
11468 }
11469 for (option_id, value_json) in writes {
11470 if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11471 tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11472 }
11473 }
11474 if effort_gap {
11475 tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11476 }
11477 tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11478 }
11479
11480 fn mark_spawn_provisional(&self, address: SessionAddress) {
11481 let id = address.session.instance_id;
11482 self.provisional_spawns
11483 .lock()
11484 .unwrap_or_else(|poisoned| poisoned.into_inner())
11485 .insert(id, (address, Instant::now()));
11486 tracing::info!(
11487 node_id = %self.node_id,
11488 instance_id = id.0,
11489 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11490 "draft spawn; waiting for HQ view",
11491 );
11492 }
11493
11494 fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11495 let removed = self
11496 .provisional_spawns
11497 .lock()
11498 .unwrap_or_else(|poisoned| poisoned.into_inner())
11499 .remove(&instance_id);
11500 if removed.is_some() {
11501 tracing::info!(
11502 node_id = %self.node_id,
11503 instance_id = instance_id.0,
11504 "draft spawn claimed by HQ view",
11505 );
11506 }
11507 }
11508
11509 fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11510 let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11511 let mut map = self
11512 .provisional_spawns
11513 .lock()
11514 .unwrap_or_else(|poisoned| poisoned.into_inner());
11515 let due: Vec<AgentInstanceId> = map
11516 .iter()
11517 .filter(|(_, (_, at))| at.elapsed() >= grace)
11518 .map(|(id, _)| *id)
11519 .collect();
11520 due.into_iter()
11521 .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11522 .collect()
11523 }
11524
11525 async fn reap_provisional_spawn(&self, address: SessionAddress) {
11526 let instance_id = address.session.instance_id;
11527 tracing::warn!(
11528 node_id = %self.node_id,
11529 instance_id = instance_id.0,
11530 workspace = %address.workspace_id,
11531 reason = "attach_never_arrived",
11532 grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11533 "draft spawn never viewed; stopping PTY",
11534 );
11535 let _ = self.dispatch(ControlCommand::Stop {
11536 instance_id,
11537 force: true,
11538 });
11539 let _ = self.remove_session(&address).await;
11540 }
11541
11542 fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11543 if role != ClientRole::Operator {
11544 return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11545 }
11546 let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11547 let now = Instant::now();
11548 match controller.as_ref().copied() {
11549 Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11550 Some(active) if active.expires_at <= now => {
11551 *controller = None;
11552 Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11553 }
11554 _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11555 }
11556 }
11557
11558 fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11559 if !self
11560 .workspaces
11561 .read()
11562 .unwrap_or_else(|poisoned| poisoned.into_inner())
11563 .contains_key(&address.workspace_id)
11564 {
11565 return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11566 }
11567 let binding = self
11568 .session_bindings
11569 .lock()
11570 .unwrap_or_else(|poisoned| poisoned.into_inner())
11571 .get(&address.session.instance_id)
11572 .cloned()
11573 .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11574 if binding.workspace_id != address.workspace_id {
11575 return Err(failure(
11576 NodeFailureCode::SessionWorkspaceMismatch,
11577 "session belongs to another workspace",
11578 ));
11579 }
11580 if binding.generation != address.session.generation {
11581 return Err(failure(
11582 NodeFailureCode::StaleGeneration,
11583 &format!(
11584 "stale session generation: expected {}, received {}",
11585 binding.generation.0,
11586 address.session.generation.0,
11587 ),
11588 ));
11589 }
11590 let snapshot = self.handle.snapshot();
11591 let Some(current) = snapshot
11592 .sessions
11593 .iter()
11594 .find(|item| item.instance_id == address.session.instance_id)
11595 else {
11596 return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
11597 };
11598 if current.generation != address.session.generation {
11599 return Err(failure(
11600 NodeFailureCode::StaleGeneration,
11601 &format!(
11602 "stale session generation: expected {}, received {}",
11603 current.generation.0,
11604 address.session.generation.0,
11605 ),
11606 ));
11607 }
11608 Ok(current.agent_id.clone())
11609 }
11610
11611 fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
11612 self.dispatch_envelope(self.prepare_command(command))
11613 }
11614
11615 fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
11616 let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
11617 CommandEnvelope {
11618 id: CommandId(id),
11619 command,
11620 }
11621 }
11622
11623 fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
11624 let command_id = envelope.id;
11625 self.handle
11626 .dispatch(envelope)
11627 .map_err(|error| match error {
11628 PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
11629 PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
11630 })?;
11631 Ok(command_id)
11632 }
11633
11634 fn record_rejected_command(
11643 &self,
11644 command_id: CommandId,
11645 message: String,
11646 unsupported_transport: bool,
11647 ) {
11648 let now = Instant::now();
11649 let mut reasons = self.rejected_command_reasons.lock()
11650 .unwrap_or_else(|poisoned| poisoned.into_inner());
11651 reasons.retain(|_, reason| reason.expires_at > now);
11652 if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
11653 if let Some(&oldest) = reasons.keys().next() {
11654 reasons.remove(&oldest);
11655 }
11656 }
11657 reasons.insert(
11658 command_id,
11659 RejectedCommandReason {
11660 message,
11661 unsupported_transport,
11662 expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
11663 },
11664 );
11665 }
11666
11667 fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
11674 let now = Instant::now();
11675 let mut reasons = self.rejected_command_reasons.lock()
11676 .unwrap_or_else(|poisoned| poisoned.into_inner());
11677 match reasons.remove(&command_id) {
11678 Some(reason) if reason.expires_at > now => Some(reason),
11679 _ => None,
11680 }
11681 }
11682
11683 async fn dispatch_bounded(
11684 &self,
11685 command: ControlCommand,
11686 timeout_duration: Duration,
11687 ) -> Result<CommandId, NodeFailure> {
11688 let deadline = Instant::now() + timeout_duration;
11689 loop {
11690 match self.dispatch(command.clone()) {
11691 Ok(command_id) => return Ok(command_id),
11692 Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
11693 sleep(Duration::from_millis(2)).await;
11694 }
11695 Err(error) => return Err(error),
11696 }
11697 }
11698 }
11699
11700 fn internal_session_snapshot(
11701 &self,
11702 address: &SessionAddress,
11703 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11704 self.validate_address(address)?;
11705 self.handle
11706 .snapshot()
11707 .sessions
11708 .clone()
11709 .into_iter()
11710 .find(|session| {
11711 session.instance_id == address.session.instance_id
11712 && session.generation == address.session.generation
11713 })
11714 .ok_or_else(|| {
11715 failure(
11716 NodeFailureCode::UnknownSession,
11717 "session snapshot is unavailable",
11718 )
11719 })
11720 }
11721
11722 fn require_acp_session(
11730 &self,
11731 address: &SessionAddress,
11732 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11733 let snapshot = self.internal_session_snapshot(address)?;
11734 if snapshot.transport != TransportKind::Acp {
11735 return Err(failure(
11736 NodeFailureCode::UnsupportedTransport,
11737 "this verb requires a session spawned over the ACP transport",
11738 ));
11739 }
11740 Ok(snapshot)
11741 }
11742
11743 async fn dispatch_history_bounded(
11744 &self,
11745 address: &SessionAddress,
11746 command: ControlCommand,
11747 operation: HistoryOperation,
11748 ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
11749 self.validate_address(address)?;
11750 let started_after = self.current_sequence();
11751 let command_id = self
11752 .dispatch_bounded(
11753 command,
11754 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
11755 )
11756 .await?;
11757 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
11758 let mut scan_after = started_after;
11759 let mut accepted_after = None;
11760 loop {
11761 let events = self
11762 .history
11763 .lock()
11764 .unwrap_or_else(|poisoned| poisoned.into_inner())
11765 .events
11766 .iter()
11767 .filter(|envelope| envelope.sequence > scan_after)
11768 .cloned()
11769 .collect::<Vec<_>>();
11770 for envelope in events {
11771 scan_after = scan_after.max(envelope.sequence);
11772 let NodeEvent::Control {
11773 address: event_address,
11774 event,
11775 } = envelope.event
11776 else {
11777 continue;
11778 };
11779 if event_address != *address {
11780 continue;
11781 }
11782 if event.command_id == Some(command_id) {
11783 match &event.event {
11784 ControlEventKind::CommandRejected { .. } => {
11785 return Err(failure(
11786 NodeFailureCode::InvalidRequest,
11787 "history request was rejected",
11788 ));
11789 }
11790 ControlEventKind::HistoryRequested {
11791 operation: requested,
11792 ..
11793 } if requested == &operation => {
11794 accepted_after = Some(envelope.sequence);
11795 continue;
11796 }
11797 _ => {}
11798 }
11799 }
11800 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
11801 match (&operation, &event.event) {
11802 (
11803 HistoryOperation::Discover { .. },
11804 ControlEventKind::HistoryDiscovered { .. },
11805 )
11806 | (
11807 HistoryOperation::Load { .. },
11808 ControlEventKind::HistoryLoaded { .. },
11809 ) => return self.internal_session_snapshot(address),
11810 (_, ControlEventKind::HistoryFailed { .. }) => {
11811 return Err(failure(
11812 NodeFailureCode::BackendOperationFailed,
11813 "history operation failed",
11814 ));
11815 }
11816 _ => {}
11817 }
11818 }
11819 }
11820 if Instant::now() >= deadline {
11821 return Err(failure(
11822 NodeFailureCode::BackendBusy,
11823 if accepted_after.is_some() {
11824 "history operation did not settle before the bounded deadline"
11825 } else {
11826 "history operation was not accepted before the bounded deadline"
11827 },
11828 ));
11829 }
11830 sleep(Duration::from_millis(2)).await;
11831 }
11832 }
11833
11834 async fn discover_history(
11835 &self,
11836 address: &SessionAddress,
11837 limit: u16,
11838 ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
11839 let query = HistoryQuery {
11840 working_directory: Some(self.workspace_root(&address.workspace_id)?),
11841 limit,
11842 };
11843 query
11844 .validate()
11845 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
11846 let snapshot = self
11847 .dispatch_history_bounded(
11848 address,
11849 ControlCommand::DiscoverHistory {
11850 instance_id: address.session.instance_id,
11851 query: query.clone(),
11852 },
11853 HistoryOperation::Discover { query },
11854 )
11855 .await?;
11856 if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
11857 return Err(failure(
11858 NodeFailureCode::BackendOperationFailed,
11859 "history discovery settled into an invalid state",
11860 ));
11861 }
11862 Ok(snapshot.history.candidates)
11863 }
11864
11865 async fn load_history(
11866 &self,
11867 address: &SessionAddress,
11868 candidate_id: String,
11869 ) -> Result<HistorySessionRecord, NodeFailure> {
11870 validate_candidate_id(&candidate_id)
11871 .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
11872 let snapshot = self
11873 .dispatch_history_bounded(
11874 address,
11875 ControlCommand::LoadHistory {
11876 instance_id: address.session.instance_id,
11877 candidate_id: candidate_id.clone(),
11878 },
11879 HistoryOperation::Load {
11880 candidate_id: candidate_id.clone(),
11881 },
11882 )
11883 .await?;
11884 if snapshot.history.pending.is_some()
11885 || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
11886 {
11887 return Err(failure(
11888 NodeFailureCode::BackendOperationFailed,
11889 "history load settled into an invalid state",
11890 ));
11891 }
11892 snapshot.history.loaded.ok_or_else(|| {
11893 failure(
11894 NodeFailureCode::BackendOperationFailed,
11895 "history load produced no bounded session",
11896 )
11897 })
11898 }
11899
11900 async fn export_context_pack_for_session_record(
11907 &self,
11908 record_id: &SessionRecordId,
11909 session: &SessionAddress,
11910 allow_clean_detachment: bool,
11911 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11912 let outcome = self
11918 .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
11919 .await;
11920 if let Err(failure) = &outcome {
11921 tracing::warn!(
11922 record = %record_id,
11923 code = ?failure.code,
11924 reason = %failure.message,
11925 "session-record context-pack export refused",
11926 );
11927 }
11928 outcome
11929 }
11930
11931 async fn export_context_pack_for_session_record_inner(
11932 &self,
11933 record_id: &SessionRecordId,
11934 session: &SessionAddress,
11935 allow_clean_detachment: bool,
11936 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
11937 let step = |name: &'static str| move |failure: NodeFailure| {
11942 tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
11943 failure
11944 };
11945 let record = self.record(record_id).map_err(step("record-lookup"))?;
11946 let identity = match record.provider_session.clone() {
11962 Some(identity) => {
11963 if identity.key != ProviderSessionKey::SessionId {
11964 return Err(step("provider-identity-key")(failure(
11965 NodeFailureCode::BackendOperationFailed,
11966 "managed session provider identity cannot bind history exactly",
11967 )));
11968 }
11969 Some(identity)
11970 }
11971 None if record.state == ManagedSessionState::IdentityPending => {
11972 return Err(step("provider-session-identity")(failure(
11973 NodeFailureCode::BackendOperationFailed,
11974 "managed session has not yet observed its provider session identity",
11975 )));
11976 }
11977 None => None,
11978 };
11979 self.revalidate_session_record_context_export(
11980 &record,
11981 identity.as_ref(),
11982 session,
11983 allow_clean_detachment,
11984 ).map_err(step("revalidate-before-discovery"))?;
11985 let candidates = self
11986 .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
11987 .await
11988 .map_err(step("discover-history"))?;
11989 let candidate_id = select_history_candidate(
11994 &candidates,
11995 identity.as_ref().map(|identity| identity.id.as_str()),
11996 ).map_err(step("unique-candidate"))?;
11997 let loaded = self.load_history(session, candidate_id.clone()).await
11998 .map_err(step("load-history"))?;
11999 if let Some(identity) = identity.as_ref() {
12000 if loaded.session_id != identity.id {
12001 return Err(failure(
12002 NodeFailureCode::SessionRecordConflict,
12003 "loaded history identity does not match the managed session record",
12004 ));
12005 }
12006 }
12007 self.revalidate_session_record_context_export(
12008 &record,
12009 identity.as_ref(),
12010 session,
12011 allow_clean_detachment,
12012 )?;
12013 self.revalidate_loaded_history(session, &candidate_id, &loaded)
12014 .map_err(step("revalidate-loaded-history"))?;
12015 let pack = self.materialize_context_pack(session).await
12016 .map_err(step("materialize-context-pack"))?;
12017 self.commit_context_pack_for_session_record(
12018 &record,
12019 identity.as_ref(),
12020 session,
12021 &candidate_id,
12022 &loaded,
12023 pack,
12024 allow_clean_detachment,
12025 )
12026 }
12027
12028 async fn export_context_pack(
12029 &self,
12030 address: &SessionAddress,
12031 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12032 let pack = self.materialize_context_pack(address).await?;
12033 self.commit_context_pack(pack)
12034 }
12035
12036 async fn materialize_context_pack(
12037 &self,
12038 address: &SessionAddress,
12039 ) -> Result<NodeContextPack, NodeFailure> {
12040 let snapshot = self.internal_session_snapshot(address)?;
12041 if !context_pack_source_status_is_usable(&snapshot.status) {
12042 return Err(failure(
12043 NodeFailureCode::InvalidRequest,
12044 "context export requires a running or successfully exited source session",
12045 ));
12046 }
12047 if snapshot.history.pending.is_some()
12048 || snapshot.history.loaded_candidate_id.is_none()
12049 {
12050 return Err(failure(
12051 NodeFailureCode::InvalidRequest,
12052 "context export requires settled loaded history",
12053 ));
12054 }
12055 let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12056 failure(
12057 NodeFailureCode::InvalidRequest,
12058 "context export requires settled loaded history",
12059 )
12060 })?;
12061 let source_provider = snapshot.agent_id.clone();
12062 let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12063 let repository = self
12064 .context_pack_repository(&address.workspace_id)
12065 .await?;
12066 let current = self.internal_session_snapshot(address)?;
12067 if !context_pack_source_status_is_usable(¤t.status)
12068 || current.agent_id != source_provider
12069 || current.history.pending.is_some()
12070 || current.history.loaded_candidate_id != loaded_candidate_id
12071 || current.history.loaded.as_ref() != Some(&loaded)
12072 {
12073 return Err(failure(
12074 NodeFailureCode::ContextPackMaterializationFailed,
12075 "context source changed during bounded repository capture",
12076 ));
12077 }
12078 NodeContextPack::export_with_repository(
12079 ContextPackLineageReceipt {
12080 source_node_id: self.node_id.clone(),
12081 source_session: address.clone(),
12082 source_provider,
12083 },
12084 &loaded,
12085 Some(repository),
12086 )
12087 .map_err(|_| {
12088 failure(
12089 NodeFailureCode::ContextPackMaterializationFailed,
12090 "bounded context pack export failed",
12091 )
12092 })
12093 }
12094
12095 fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12101 let mut store = self
12102 .context_pack_store
12103 .lock()
12104 .unwrap_or_else(|poisoned| poisoned.into_inner());
12105 let Some(store) = store.as_mut() else {
12106 return Ok(());
12107 };
12108 store.commit(pack).map_err(|_| {
12109 failure(
12110 NodeFailureCode::ContextPackMaterializationFailed,
12111 "durable context pack commit failed",
12112 )
12113 })
12114 }
12115
12116 fn commit_context_pack(
12117 &self,
12118 pack: NodeContextPack,
12119 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12120 self.commit_context_pack_durably(&pack)?;
12121 self.context_catalog
12122 .write()
12123 .unwrap_or_else(|poisoned| poisoned.into_inner())
12124 .insert(pack)
12125 .map_err(|_| {
12126 failure(
12127 NodeFailureCode::ContextPackMaterializationFailed,
12128 "context pack catalog is full",
12129 )
12130 })
12131 }
12132
12133 fn commit_context_pack_for_session_record(
12134 &self,
12135 expected: &ManagedSessionRecord,
12136 identity: Option<&ProviderSessionIdentity>,
12137 session: &SessionAddress,
12138 candidate_id: &str,
12139 loaded: &HistorySessionRecord,
12140 pack: NodeContextPack,
12141 allow_clean_detachment: bool,
12142 ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12143 let current_root = self.workspace_root(&expected.workspace_id)?;
12144 let _transaction = self
12145 .state_transaction
12146 .lock()
12147 .unwrap_or_else(|poisoned| poisoned.into_inner());
12148 let records = self
12149 .session_records
12150 .lock()
12151 .unwrap_or_else(|poisoned| poisoned.into_inner());
12152 let current = records.records.get(&expected.record_id).ok_or_else(|| {
12153 failure(
12154 NodeFailureCode::SessionRecordConflict,
12155 "managed session record disappeared during context export",
12156 )
12157 })?;
12158 if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12159 return Err(failure(
12160 NodeFailureCode::SessionRecordConflict,
12161 "managed session record changed during context export",
12162 ));
12163 }
12164 let bindings = self
12165 .session_bindings
12166 .lock()
12167 .unwrap_or_else(|poisoned| poisoned.into_inner());
12168 let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12169 failure(
12170 NodeFailureCode::SessionRecordConflict,
12171 "managed session runtime binding disappeared during context export",
12172 )
12173 })?;
12174 let runtime = self
12175 .handle
12176 .snapshot()
12177 .sessions
12178 .iter()
12179 .find(|snapshot| {
12180 snapshot.instance_id == session.session.instance_id
12181 && snapshot.generation == session.session.generation
12182 })
12183 .cloned()
12184 .ok_or_else(|| {
12185 failure(
12186 NodeFailureCode::SessionRecordConflict,
12187 "managed session runtime disappeared during context export",
12188 )
12189 })?;
12190 let receipt = pack.receipt();
12191 if !session_record_context_export_binding_is_exact(
12192 expected,
12193 current,
12194 identity,
12195 session,
12196 binding,
12197 &runtime.agent_id,
12198 ¤t_root,
12199 allow_clean_detachment,
12200 ) || !session_record_context_export_source_is_usable(
12201 current,
12202 &runtime.status,
12203 allow_clean_detachment,
12204 ) || runtime.history.pending.is_some()
12205 || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12206 || runtime.history.loaded.as_ref() != Some(loaded)
12207 || receipt.lineage.source_node_id != self.node_id
12208 || receipt.lineage.source_session != *session
12209 || receipt.lineage.source_provider != current.provider
12210 || receipt.source_message_count != loaded.message_count
12211 {
12212 return Err(failure(
12213 NodeFailureCode::SessionRecordConflict,
12214 "managed session source changed before context catalog commit",
12215 ));
12216 }
12217 self.commit_context_pack_durably(&pack)?;
12218 self.context_catalog
12219 .write()
12220 .unwrap_or_else(|poisoned| poisoned.into_inner())
12221 .insert(pack)
12222 .map_err(|_| {
12223 failure(
12224 NodeFailureCode::ContextPackMaterializationFailed,
12225 "context pack catalog is full",
12226 )
12227 })
12228 }
12229
12230 async fn context_pack_repository(
12231 &self,
12232 workspace_id: &WorkspaceId,
12233 ) -> Result<ContextPackRepository, NodeFailure> {
12234 let first = self
12235 .context_pack_repository_observation(workspace_id)
12236 .await?;
12237 let second = self
12238 .context_pack_repository_observation(workspace_id)
12239 .await?;
12240 stable_context_pack_repository(first, second)
12241 }
12242
12243 async fn context_pack_repository_observation(
12244 &self,
12245 workspace_id: &WorkspaceId,
12246 ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12247 let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12248 let head = if git.is_repository {
12249 let root = self.workspace_root(workspace_id)?;
12250 match resolve_base_commit_with_timeout(
12251 &root,
12252 "HEAD",
12253 GIT_COMMAND_TIMEOUT_MS,
12254 )
12255 .await
12256 {
12257 Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12258 Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12259 ContextPackRepositoryHead::Unborn
12260 }
12261 Err(_) => {
12262 return Err(failure(
12263 NodeFailureCode::ContextPackMaterializationFailed,
12264 "context pack HEAD observation failed",
12265 ));
12266 }
12267 }
12268 } else {
12269 ContextPackRepositoryHead::NotRepository
12270 };
12271 let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12272 for selected_path in CONTEXT_PACK_SELECTED_FILES {
12273 let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12274 failure(
12275 NodeFailureCode::InvalidRepositoryPath,
12276 "context pack selected file path is invalid",
12277 )
12278 })?;
12279 match self.read_workspace_file(workspace_id.clone(), path).await {
12280 Ok(WorkspaceFileRead {
12281 content: WorkspaceFileContent::Utf8 { text, byte_len },
12282 ..
12283 }) => files.push(ContextPackRepositoryFileSource::utf8(
12284 selected_path,
12285 text,
12286 byte_len,
12287 )),
12288 Ok(WorkspaceFileRead {
12289 content: WorkspaceFileContent::NonUtf8 { byte_len },
12290 ..
12291 }) => files.push(ContextPackRepositoryFileSource::skipped(
12292 selected_path,
12293 ContextPackSelectedFileSkipReason::NonUtf8,
12294 Some(byte_len),
12295 )),
12296 Ok(WorkspaceFileRead {
12297 content: WorkspaceFileContent::TooLarge { .. },
12298 ..
12299 }) => files.push(ContextPackRepositoryFileSource::skipped(
12300 selected_path,
12301 ContextPackSelectedFileSkipReason::TooLarge,
12302 None,
12303 )),
12304 Err(error) => {
12305 let reason = if matches!(
12306 error.code,
12307 NodeFailureCode::InvalidRepositoryPath
12308 | NodeFailureCode::RepositoryFileNotRegular
12309 | NodeFailureCode::RepositoryPathUnsafe
12310 ) {
12311 ContextPackSelectedFileSkipReason::Unsafe
12312 } else {
12313 ContextPackSelectedFileSkipReason::Unavailable
12314 };
12315 files.push(ContextPackRepositoryFileSource::skipped(
12316 selected_path,
12317 reason,
12318 None,
12319 ));
12320 }
12321 }
12322 }
12323 Ok(ContextPackRepositoryObservation { head, git, files })
12324 }
12325
12326 fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12327 let referenced_by_binding = self
12328 .session_bindings
12329 .lock()
12330 .unwrap_or_else(|poisoned| poisoned.into_inner())
12331 .values()
12332 .any(|binding| {
12333 binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12334 });
12335 let referenced_by_record = self
12336 .session_records
12337 .lock()
12338 .unwrap_or_else(|poisoned| poisoned.into_inner())
12339 .records
12340 .values()
12341 .any(|record| record.context_id.as_ref() == Some(context_id));
12342 let referenced_by_materialization = self
12343 .materializations
12344 .lock()
12345 .unwrap_or_else(|poisoned| poisoned.into_inner())
12346 .values()
12347 .any(|ownership| {
12348 ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12349 });
12350 if referenced_by_binding
12351 || referenced_by_record
12352 || referenced_by_materialization
12353 {
12354 return Err(failure(
12355 NodeFailureCode::ContextPackBusy,
12356 "context pack is still referenced",
12357 ));
12358 }
12359 let mut catalog = self.context_catalog
12360 .write()
12361 .unwrap_or_else(|poisoned| poisoned.into_inner());
12362 if catalog.get(context_id).is_none() {
12363 return Err(failure(
12364 NodeFailureCode::UnknownContextPack,
12365 "context pack is unavailable",
12366 ));
12367 }
12368 self.spawn_idempotency
12369 .lock()
12370 .unwrap_or_else(|poisoned| poisoned.into_inner())
12371 .entries
12372 .retain(|_, entry| !entry.value.references_context(context_id));
12373 let removed = catalog.remove(context_id);
12374 debug_assert!(removed.is_some(), "validated context pack remains present");
12375 Ok(())
12376 }
12377
12378 async fn dispatch_input_bounded(
12379 &self,
12380 address: &SessionAddress,
12381 action: InputAction,
12382 ) -> Result<(), NodeFailure> {
12383 let started_after = self.current_sequence();
12384 self.pty_input_marks
12385 .lock()
12386 .unwrap_or_else(|poisoned| poisoned.into_inner())
12387 .insert(address.session.instance_id, (Instant::now(), false));
12388 self.pty_input_wake.notify_one();
12389 let command_id = self
12390 .dispatch_bounded(
12391 ControlCommand::SendInput {
12392 instance_id: address.session.instance_id,
12393 action,
12394 },
12395 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12396 )
12397 .await?;
12398 let deadline = Instant::now() + Duration::from_millis(self.input_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::InputRequested { .. } => {
12432 accepted_after = Some(envelope.sequence);
12433 continue;
12434 }
12435 _ => {}
12436 }
12437 }
12438 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12439 match &event.event {
12440 ControlEventKind::InputCompleted { .. } => return Ok(()),
12441 ControlEventKind::InputFailed { message, .. } => {
12442 return Err(failure(
12443 NodeFailureCode::BackendOperationFailed,
12444 message,
12445 ));
12446 }
12447 _ => {}
12448 }
12449 }
12450 }
12451 if Instant::now() >= deadline {
12452 return Err(failure(
12453 NodeFailureCode::BackendBusy,
12454 if accepted_after.is_some() {
12455 "input was accepted but did not settle before the bounded deadline"
12456 } else {
12457 "input was not accepted before the bounded deadline"
12458 },
12459 ));
12460 }
12461 sleep(Duration::from_millis(2)).await;
12462 }
12463 }
12464
12465 async fn dispatch_resize_bounded(
12466 &self,
12467 address: &SessionAddress,
12468 size: gate4agent_types::TerminalSize,
12469 ) -> Result<(), NodeFailure> {
12470 let started_after = self.current_sequence();
12471 let command_id = self
12472 .dispatch_bounded(
12473 ControlCommand::Resize {
12474 instance_id: address.session.instance_id,
12475 size,
12476 },
12477 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12478 )
12479 .await?;
12480 let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12481 let mut scan_after = started_after;
12482 let mut accepted_after = None;
12483 loop {
12484 let events = self
12485 .history
12486 .lock()
12487 .unwrap_or_else(|poisoned| poisoned.into_inner())
12488 .events
12489 .iter()
12490 .filter(|envelope| envelope.sequence > scan_after)
12491 .cloned()
12492 .collect::<Vec<_>>();
12493 for envelope in events {
12494 scan_after = scan_after.max(envelope.sequence);
12495 let NodeEvent::Control {
12496 address: event_address,
12497 event,
12498 } = envelope.event
12499 else {
12500 continue;
12501 };
12502 if event_address != *address {
12503 continue;
12504 }
12505 if event.command_id == Some(command_id) {
12506 match &event.event {
12507 ControlEventKind::CommandRejected { message } => {
12508 return Err(failure(NodeFailureCode::InvalidRequest, message));
12509 }
12510 ControlEventKind::ResizeRequested {
12511 size: requested_size,
12512 ..
12513 } if *requested_size == size => {
12514 accepted_after = Some(envelope.sequence);
12515 continue;
12516 }
12517 _ => {}
12518 }
12519 }
12520 if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12521 match &event.event {
12522 ControlEventKind::Resized { size: settled_size }
12523 if *settled_size == size =>
12524 {
12525 return Ok(());
12526 }
12527 ControlEventKind::ResizeFailed { message } => {
12528 return Err(failure(
12529 NodeFailureCode::BackendOperationFailed,
12530 message,
12531 ));
12532 }
12533 _ => {}
12534 }
12535 }
12536 }
12537 if Instant::now() >= deadline {
12538 return Err(failure(
12539 NodeFailureCode::BackendBusy,
12540 if accepted_after.is_some() {
12541 "resize was accepted but did not settle before the bounded deadline"
12542 } else {
12543 "resize was not accepted before the bounded deadline"
12544 },
12545 ));
12546 }
12547 sleep(Duration::from_millis(2)).await;
12548 }
12549 }
12550
12551 async fn spawn_session(
12552 &self,
12553 workspace_id: WorkspaceId,
12554 provider: AgentId,
12555 mode: SessionMode,
12556 terminal_size: gate4agent_types::TerminalSize,
12557 initial_prompt: Option<String>,
12558 ) -> Result<SessionAddress, NodeFailure> {
12559 if self.managed_worktrees.lock()
12560 .unwrap_or_else(|poisoned| poisoned.into_inner())
12561 .lease_for_workspace(&workspace_id)
12562 .is_some()
12563 {
12564 return Err(failure(
12565 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12566 "managed worktree targets may only be spawned through their lease request",
12567 ));
12568 }
12569 self
12570 .spawn_session_with_deadline(
12571 workspace_id,
12572 provider,
12573 mode,
12574 terminal_size,
12575 initial_prompt,
12576 None,
12577 None,
12578 None,
12579 None,
12580 None,
12581 SpawnRecordPolicy::ProviderIdentityOnly,
12582 None,
12583 &[],
12584 None,
12585 ApprovalLevel::default(),
12591 None,
12592 None,
12593 )
12594 .await
12595 .map(|(session, _runtime_policy)| session)
12596 }
12597
12598 async fn spawn_session_with_deadline(
12599 &self,
12600 workspace_id: WorkspaceId,
12601 provider: AgentId,
12602 mode: SessionMode,
12603 terminal_size: gate4agent_types::TerminalSize,
12604 initial_prompt: Option<String>,
12605 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
12606 bundle: Option<ResolvedBundleReceipt>,
12607 context: Option<ResolvedContextPackReceipt>,
12608 managed_authority: Option<ManagedWorktreeLeaseId>,
12609 admitted_runtime_policy: Option<ProviderRuntimePolicy>,
12610 record_policy: SpawnRecordPolicy,
12611 deadline: Option<Instant>,
12612 required_capabilities: &[ProviderRuntimeCapability],
12613 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
12614 approval_level: ApprovalLevel,
12615 network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
12616 browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
12622 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
12623 let reserved_lease = self.managed_worktrees.lock()
12624 .unwrap_or_else(|poisoned| poisoned.into_inner())
12625 .lease_for_workspace(&workspace_id);
12626 match (reserved_lease.as_ref(), managed_authority.as_ref()) {
12627 (Some(expected), Some(provided)) if expected == provided => {}
12628 (None, None) => {}
12629 _ => {
12630 return Err(failure(
12631 NodeFailureCode::ManagedWorktreeOwnershipConflict,
12632 "spawn lacks exact managed worktree lease authority",
12633 ));
12634 }
12635 }
12636 self.ensure_workspace_environment_profile_compatible(
12640 &workspace_id,
12641 environment_profile.as_ref(),
12642 )?;
12643 let runtime_requirement = match (mode, initial_prompt.is_some()) {
12644 (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
12645 (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
12646 (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
12647 (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
12648 };
12649 let transport = match mode {
12655 SessionMode::Pty => TransportKind::Pty,
12656 SessionMode::Inline => TransportKind::Pipe,
12657 SessionMode::Acp => TransportKind::Acp,
12658 };
12659 let pipe_transport = self.provider_supports_pipe_transport(&provider);
12660 let acp_transport = self.provider_supports_acp_transport(&provider);
12661 let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
12662 require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
12663 NodeFailureCode::UnsupportedSpawnCapability,
12664 "pre-admitted provider runtime no longer satisfies spawn requirements",
12665 ))?;
12666 runtime_policy
12667 } else if let Some(deadline) = deadline {
12668 let remaining = spawn_deadline_remaining(deadline)?;
12669 timeout(
12670 remaining,
12671 self.admit_provider_runtime(&provider, runtime_requirement),
12672 )
12673 .await
12674 .map_err(|_| {
12675 failure(
12676 NodeFailureCode::SpawnDeadlineExceeded,
12677 "provider admission exceeded the spawn deadline",
12678 )
12679 })??
12680 } else {
12681 self.admit_provider_runtime(&provider, runtime_requirement).await?
12682 };
12683 let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
12684 let runtime_policy =
12696 crate::provider_runtime::policy_for_transport(transport, runtime_policy);
12697 if required_capabilities
12698 .iter()
12699 .any(|capability| !runtime_policy.admits(*capability))
12700 {
12701 return Err(failure(
12702 NodeFailureCode::UnsupportedSpawnCapability,
12703 "provider runtime does not admit a required spawn capability",
12704 ));
12705 }
12706 if let Some(deadline) = deadline {
12707 spawn_deadline_remaining(deadline)?;
12708 }
12709 self.ensure_binding_capacity()?;
12710 let working_directory = self.workspace_root(&workspace_id)?;
12711 let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
12712 let session = SessionKey {
12713 instance_id,
12714 generation: SessionGeneration(1),
12715 };
12716 let address = SessionAddress {
12717 workspace_id: workspace_id.clone(),
12718 session,
12719 };
12720 tracing::info!(
12728 node_id = %self.node_id,
12729 workspace_id = %workspace_id,
12730 provider = %provider,
12731 environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
12732 session = ?address.session,
12733 "session spawn request accepted",
12734 );
12735 let mut prepared_spawn = SpawnPreparer::prepare(
12736 self,
12737 SpawnPrepareRequest {
12738 instance_id,
12739 address: &address,
12740 provider: &provider,
12741 mode,
12742 environment_profile: environment_profile.as_ref(),
12743 bundle: bundle.as_ref(),
12744 context: context.as_ref(),
12745 managed_authority: managed_authority.as_ref(),
12746 harness_mcp,
12747 approval_level,
12748 network_allowlist: network_allowlist.as_ref(),
12749 browser_profile_id,
12750 },
12751 )?;
12752 let materialization_id = prepared_spawn.materialization_id.take();
12753 let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
12754 let record_id = self.bind_spawn_session_with_materialization(
12755 &address,
12756 provider.clone(),
12757 mode,
12758 runtime_policy,
12759 record_policy,
12760 environment_profile,
12761 bundle,
12762 context,
12763 materialization_id,
12764 )?;
12765 prepared_spawn.retain_session_guards();
12766 let agent_id = provider;
12770 let register_command_id = match self.dispatch_bounded(
12771 ControlCommand::Register {
12772 instance_id,
12773 agent_id,
12774 transport,
12775 },
12776 dispatch_timeout,
12777 )
12778 .await {
12779 Ok(command_id) => command_id,
12780 Err(error) => {
12781 if let Some(binding) = self.remove_binding(&address) {
12782 SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
12783 }
12784 if let Some(record_id) = record_id.as_ref() {
12785 self.discard_record(record_id)?;
12786 }
12787 return Err(spawn_dispatch_error(error, deadline));
12788 }
12789 };
12790 let start_timeout = match spawn_dispatch_timeout(deadline) {
12791 Ok(timeout) => timeout,
12792 Err(error) => {
12793 let recovery = self
12794 .rollback_spawn(
12795 &address,
12796 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12797 )
12798 .await;
12799 return match recovery {
12800 Ok(()) => {
12801 if let Some(record_id) = record_id.as_ref() {
12802 self.discard_record(record_id)?;
12803 }
12804 Err(error)
12805 }
12806 Err(recovery_error) => {
12807 if let Some(record_id) = record_id.as_ref() {
12808 self.mark_record_error(
12809 record_id,
12810 "spawn deadline elapsed and registration recovery failed",
12811 )?;
12812 }
12813 Err(recovery_error)
12814 }
12815 };
12816 }
12817 };
12818 if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
12819 if let Err(recovery_error) = self
12820 .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
12821 .await
12822 {
12823 return Err(recovery_error);
12824 }
12825 if let Some(record_id) = record_id.as_ref() {
12826 self.discard_record(record_id)?;
12827 }
12828 return Err(start_error);
12829 }
12830 let start_result = self
12831 .dispatch_bounded(
12832 ControlCommand::Start {
12833 instance_id,
12834 runtime_policy,
12835 request: StartRequest {
12836 working_directory,
12837 terminal_size,
12838 initial_prompt,
12839 session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
12840 approval_level,
12841 },
12842 },
12843 start_timeout,
12844 )
12845 .await;
12846 let start_command_id = match start_result {
12847 Ok(command_id) => command_id,
12848 Err(start_error) => {
12849 let start_error = spawn_dispatch_error(start_error, deadline);
12850 let recovery = self
12851 .rollback_spawn(
12852 &address,
12853 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12854 )
12855 .await;
12856 return match recovery {
12857 Ok(()) => {
12858 if let Some(record_id) = record_id.as_ref() {
12859 self.discard_record(record_id)?;
12860 }
12861 Err(start_error)
12862 }
12863 Err(recovery_error) => {
12864 let diagnostic = format!(
12865 "start failed and registration recovery failed: {}",
12866 recovery_error.message,
12867 );
12868 if let Some(record_id) = record_id.as_ref() {
12869 self.mark_record_error(record_id, &diagnostic)?;
12870 }
12871 Err(failure(recovery_error.code, &diagnostic))
12872 }
12873 };
12874 }
12875 };
12876
12877 let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
12878 loop {
12879 if let Some(reason) = self.take_rejected_command_reason(register_command_id)
12888 .or_else(|| self.take_rejected_command_reason(start_command_id))
12889 {
12890 self.rollback_spawn(
12891 &address,
12892 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12893 ).await.map_err(|error| {
12894 failure(
12895 error.code,
12896 &format!(
12897 "spawn was rejected ({}) and registration recovery failed: {}",
12898 reason.message, error.message
12899 ),
12900 )
12901 })?;
12902 if let Some(record_id) = record_id.as_ref() {
12903 self.discard_record(record_id)?;
12904 }
12905 let code = if reason.unsupported_transport {
12906 NodeFailureCode::UnsupportedTransport
12907 } else {
12908 NodeFailureCode::BackendOperationFailed
12909 };
12910 return Err(failure(code, &reason.message));
12911 }
12912 if let Some(current) = self
12913 .handle
12914 .snapshot()
12915 .sessions
12916 .iter()
12917 .find(|current| current.instance_id == instance_id)
12918 {
12919 if current.generation != session.generation {
12920 let divergence = failure(
12921 NodeFailureCode::StaleGeneration,
12922 "spawn generation diverged",
12923 );
12924 return match self
12925 .rollback_spawn(
12926 &address,
12927 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12928 )
12929 .await
12930 {
12931 Ok(()) => {
12932 if let Some(record_id) = record_id.as_ref() {
12933 self.discard_record(record_id)?;
12934 }
12935 Err(divergence)
12936 }
12937 Err(recovery_error) => {
12938 if let Some(record_id) = record_id.as_ref() {
12939 self.mark_record_error(
12940 record_id,
12941 "spawn generation diverged and recovery failed",
12942 )?;
12943 }
12944 Err(recovery_error)
12945 }
12946 };
12947 }
12948 if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
12949 prepared_spawn.retain_launch_guards();
12950 return Ok((address, runtime_policy));
12951 }
12952 }
12953 if Instant::now() >= commit_deadline {
12954 self.rollback_spawn(
12955 &address,
12956 Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12957 ).await.map_err(|error| {
12958 failure(
12959 error.code,
12960 &format!(
12961 "start did not commit and registration recovery failed: {}",
12962 error.message
12963 ),
12964 )
12965 })?;
12966 if let Some(record_id) = record_id.as_ref() {
12967 self.discard_record(record_id)?;
12968 }
12969 return Err(failure(
12970 if deadline.is_some() {
12971 NodeFailureCode::SpawnDeadlineExceeded
12972 } else {
12973 NodeFailureCode::BackendBusy
12974 },
12975 "start did not commit before the bounded deadline; registration was removed",
12976 ));
12977 }
12978 sleep(Duration::from_millis(2)).await;
12979 }
12980 }
12981
12982 fn effective_spawn_runtime_policy(
12983 &self,
12984 runtime_policy: ProviderRuntimePolicy,
12985 ) -> ProviderRuntimePolicy {
12986 #[cfg(feature = "fixture")]
12987 let runtime_policy = if self.fixture_semantic_hook_policy {
12988 ProviderRuntimePolicy::new(true, true, true, true, false, true)
12989 .expect("monitoring Hook fixture policy is internally valid")
12990 } else {
12991 runtime_policy
12992 };
12993 runtime_policy
12994 }
12995
12996 fn verify_harness_mcp_before_start(
12997 &self,
12998 prepared: Option<&PreparedHarnessMcpSpawn>,
12999 ) -> Result<(), NodeFailure> {
13000 prepared
13001 .map(PreparedHarnessMcpSpawn::verify_helper_program)
13002 .transpose()
13003 .map(|_| ())
13004 .map_err(harness_mcp_failure)
13005 }
13006
13007 async fn rollback_spawn(
13008 &self,
13009 address: &SessionAddress,
13010 commit_timeout: Duration,
13011 ) -> Result<(), NodeFailure> {
13012 self.dispatch_bounded(
13013 ControlCommand::Remove {
13014 instance_id: address.session.instance_id,
13015 },
13016 commit_timeout,
13017 )
13018 .await?;
13019 self.wait_until_removed(address, commit_timeout).await?;
13020 if let Some(binding) = self.remove_binding(address) {
13021 SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13022 }
13023 Ok(())
13024 }
13025
13026 async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13027 let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13028 self.dispatch_bounded(
13029 ControlCommand::Remove {
13030 instance_id: address.session.instance_id,
13031 },
13032 commit_timeout,
13033 )
13034 .await?;
13035 self.wait_until_removed(address, commit_timeout).await?;
13036 let removed = self.remove_binding(address);
13037 if let Some(binding) = removed.as_ref() {
13038 SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13039 }
13040 if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13041 let snapshot = self.managed_worktrees.lock()
13042 .unwrap_or_else(|poisoned| poisoned.into_inner())
13043 .release_session(
13044 &lease_id,
13045 self.incarnation_id,
13046 address.session.instance_id,
13047 address.session.generation,
13048 unix_time_ms(),
13049 );
13050 if self.persist_state().is_err() {
13051 self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13052 }
13053 if let Some(snapshot) = snapshot {
13054 self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13055 }
13056 let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13057 }
13058 Ok(())
13059 }
13060
13061 async fn wait_until_removed(
13107 &self,
13108 address: &SessionAddress,
13109 commit_timeout: Duration,
13110 ) -> Result<(), NodeFailure> {
13111 let deadline = Instant::now() + commit_timeout;
13112 let mut next_dispatch = Instant::now();
13113 loop {
13114 let snapshot = self.handle.snapshot();
13115 let status = snapshot
13116 .sessions
13117 .iter()
13118 .find(|session| session.instance_id == address.session.instance_id)
13119 .map(|session| session.status.clone());
13120 let Some(status) = status else {
13121 return Ok(());
13122 };
13123 let now = Instant::now();
13124 if now >= deadline {
13125 return Err(failure(
13126 NodeFailureCode::BackendBusy,
13127 "remove did not commit before the bounded deadline; session binding was retained",
13128 ));
13129 }
13130 if status.allows_remove() && now >= next_dispatch {
13131 let _ = self.dispatch(ControlCommand::Remove {
13132 instance_id: address.session.instance_id,
13133 });
13134 next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13135 }
13136 sleep(Duration::from_millis(2)).await;
13137 }
13138 }
13139
13140 fn resync(&self, after_sequence: u64) -> NodeResponse {
13141 let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13142 let event_sequence = history.last_sequence;
13143 let oldest_available_sequence = history.replay_floor_sequence;
13144 let events = history
13145 .events
13146 .iter()
13147 .filter(|event| event.sequence > after_sequence)
13148 .cloned()
13149 .collect();
13150 drop(history);
13151 NodeResponse::Resync {
13152 event_sequence,
13153 oldest_available_sequence,
13154 snapshot: self.snapshot(),
13155 events,
13156 }
13157 }
13158}
13159
13160fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13165 match kind {
13166 AgentStreamChunkKindV1::Text { .. } => "text",
13167 AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13168 AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13169 AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13170 AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13171 AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13172 AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13173 }
13174}
13175
13176async fn accept_connections(
13190 endpoint: &str,
13191 call_home: Option<std::net::SocketAddr>,
13192 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13193 shared: Arc<NodeShared>,
13194) -> Result<(), NodeServerError> {
13195 let mut connections = JoinSet::new();
13196 let result = match call_home {
13197 None => accept_connections_inner(endpoint, shared, &mut connections).await,
13198 Some(relay) => {
13199 let listener =
13200 accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13201 let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13202 tokio::pin!(listener);
13203 tokio::pin!(dialler);
13204 tokio::select! {
13205 result = &mut listener => result,
13206 result = &mut dialler => result,
13207 }
13208 }
13209 };
13210 let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13211 while !connections.is_empty() {
13212 let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13213 if remaining.is_zero()
13214 || timeout(remaining, connections.join_next()).await.is_err()
13215 {
13216 break;
13217 }
13218 }
13219 connections.abort_all();
13220 while connections.join_next().await.is_some() {}
13221 result
13222}
13223
13224const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13228
13229async fn call_home_loop(
13243 relay: std::net::SocketAddr,
13244 identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13245 shared: Arc<NodeShared>,
13246) -> Result<(), NodeServerError> {
13247 let mut failures = 0_usize;
13248 loop {
13249 if shared.shutdown.load(Ordering::Acquire) {
13250 return Ok(());
13251 }
13252 let permit = tokio::select! {
13253 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13254 permit.map_err(|_| NodeServerError::PreauthClosed)?
13255 }
13256 _ = shared.shutdown_notify.notified() => return Ok(()),
13257 };
13258 match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13259 Ok(()) => {
13260 failures = 0;
13264 tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13265 }
13266 Err(error) => {
13267 failures = failures.saturating_add(1);
13268 tracing::warn!(
13269 node_id = %shared.node_id,
13270 %relay,
13271 cause = %error,
13272 attempt = failures,
13273 "call-home connection failed",
13274 );
13275 let backoff = call_home_backoff(failures);
13276 tokio::select! {
13277 _ = sleep(backoff) => {}
13278 _ = shared.shutdown_notify.notified() => return Ok(()),
13279 }
13280 }
13281 }
13282 }
13283}
13284
13285const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13287const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13289
13290fn call_home_keepalive() -> socket2::TcpKeepalive {
13291 let keepalive = socket2::TcpKeepalive::new()
13292 .with_time(CALL_HOME_KEEPALIVE_IDLE)
13293 .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13294 #[cfg(not(windows))]
13295 let keepalive = keepalive.with_retries(3);
13296 keepalive
13297}
13298
13299fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13300 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13301}
13302
13303pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13305 socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13306}
13307
13308fn call_home_backoff(attempt: usize) -> Duration {
13310 Duration::from_millis(
13311 CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13312 )
13313}
13314
13315
13316async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13319 #[cfg(feature = "wireguard")]
13320 {
13321 let std_stream = tokio::task::spawn_blocking(move || {
13322 gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13323 })
13324 .await
13325 .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13326 .map_err(NodeServerError::CallHomeTransport)?;
13327 std_stream
13328 .set_nonblocking(true)
13329 .map_err(NodeServerError::CallHomeTransport)?;
13330 return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13331 }
13332 #[cfg(not(feature = "wireguard"))]
13333 {
13334 tokio::net::TcpStream::connect(relay)
13335 .await
13336 .map_err(NodeServerError::CallHomeTransport)
13337 }
13338}
13339
13340async fn dial_relay_and_serve(
13342 relay: std::net::SocketAddr,
13343 identity: Option<&hatchery_enroll::NodeIdentity>,
13344 shared: Arc<NodeShared>,
13345 preauth_permit: OwnedSemaphorePermit,
13346) -> Result<(), NodeServerError> {
13347 let mut stream = dial_call_home(relay).await?;
13348 let _ = stream.set_nodelay(true);
13352 if let Err(error) = enable_call_home_keepalive(&stream) {
13358 tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13359 }
13360 gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13361 .await
13362 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13363 tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13364 let mut session_token = None;
13365 if let Some(identity) = identity {
13366 let mut magic = [0_u8; 4];
13370 let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13371 .await
13372 .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13373 .map_err(NodeServerError::CallHomeTransport)?;
13374 if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13375 let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13376 timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13377 .await
13378 .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13379 .map_err(NodeServerError::CallHomeTransport)?;
13380 let (answer, token) = identity
13381 .answer_challenge(shared.node_id.as_str(), &frame)
13382 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13383 tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13384 tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13385 session_token = Some(token);
13386 }
13387 }
13388 serve_connection(stream, shared, session_token, preauth_permit).await
13389}
13390
13391async fn accept_connections_inner(
13392 endpoint: &str,
13393 shared: Arc<NodeShared>,
13394 connections: &mut JoinSet<Result<(), NodeServerError>>,
13395) -> Result<(), NodeServerError> {
13396 let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13397 loop {
13398 while connections.try_join_next().is_some() {}
13399 if shared.shutdown.load(Ordering::Acquire) {
13400 return Ok(());
13401 }
13402 let preauth_permit = tokio::select! {
13403 permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13404 permit.map_err(|_| NodeServerError::PreauthClosed)?
13405 }
13406 _ = shared.shutdown_notify.notified() => return Ok(()),
13407 };
13408 let server = tokio::select! {
13409 result = listener.accept() => result?,
13410 _ = shared.shutdown_notify.notified() => return Ok(()),
13411 };
13412 let connection_shared = Arc::clone(&shared);
13413 connections.spawn(async move {
13414 serve_connection(server, connection_shared, None, preauth_permit).await
13415 });
13416 if shared.shutdown.load(Ordering::Acquire) {
13417 return Ok(());
13418 }
13419 }
13420}
13421
13422async fn serve_connection<S>(
13430 mut pipe: S,
13431 shared: Arc<NodeShared>,
13432 session_token: Option<String>,
13433 preauth_permit: OwnedSemaphorePermit,
13434) -> Result<(), NodeServerError>
13435where
13436 S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13437{
13438 let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13440 let hello_frame = timeout(
13441 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13442 read_json_frame_limited_body_timeout(
13443 &mut pipe,
13444 MAX_NODE_HELLO_FRAME_BYTES,
13445 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13446 ),
13447 )
13448 .await
13449 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13450 let ClientFrame::Hello(hello) = hello_frame else {
13451 return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13452 };
13453 if hello.build_stamp != BUILD_STAMP {
13454 return Err(NodeServerError::Handshake(format!(
13455 "build stamp mismatch: local={BUILD_STAMP} remote={}",
13456 hello.build_stamp,
13457 )));
13458 }
13459 let compatibility = match hello.compatibility.as_ref() {
13460 Some(offer) => {
13461 let mut selected = node_compatibility_support(&shared)?
13462 .negotiate(offer)
13463 .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13464 project_negotiated_provider_ids(&mut selected);
13465 Some(selected)
13466 }
13467 None => None,
13468 };
13469 let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13470 let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13471 (Some(offer), Some(selected)) => negotiated_auth_proof(
13472 auth_token,
13473 AuthDirection::Server,
13474 hello.role,
13475 &hello.client_nonce,
13476 &server_nonce,
13477 offer,
13478 selected,
13479 ),
13480 (None, None) => auth_proof(
13481 auth_token,
13482 AuthDirection::Server,
13483 hello.role,
13484 &hello.client_nonce,
13485 &server_nonce,
13486 ),
13487 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13488 }
13489 .map_err(NodeServerError::Authentication)?;
13490 write_json_frame_limited(
13491 &mut pipe,
13492 &ServerFrame::Challenge(ServerChallenge {
13493 build_stamp: BUILD_STAMP.to_owned(),
13494 server_nonce,
13495 server_proof,
13496 compatibility: compatibility.clone(),
13497 }),
13498 MAX_NODE_HELLO_FRAME_BYTES,
13499 )
13500 .await?;
13501 let authentication = timeout(
13502 Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13503 read_json_frame_limited_body_timeout(
13504 &mut pipe,
13505 MAX_NODE_HELLO_FRAME_BYTES,
13506 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13507 ),
13508 )
13509 .await
13510 .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13511 let ClientFrame::Authenticate(authentication) = authentication else {
13512 return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13513 };
13514 let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13515 (Some(offer), Some(selected)) => negotiated_auth_proof(
13516 auth_token,
13517 AuthDirection::Client,
13518 hello.role,
13519 &hello.client_nonce,
13520 &server_nonce,
13521 offer,
13522 selected,
13523 ),
13524 (None, None) => auth_proof(
13525 auth_token,
13526 AuthDirection::Client,
13527 hello.role,
13528 &hello.client_nonce,
13529 &server_nonce,
13530 ),
13531 _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13532 }
13533 .map_err(NodeServerError::Authentication)?;
13534 if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13535 return Err(NodeServerError::Handshake("access denied".to_owned()));
13536 }
13537 let selected_capabilities = compatibility
13538 .as_ref()
13539 .map(|selected| selected.capabilities.clone())
13540 .unwrap_or_default();
13541 let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13542 capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13543 });
13544 let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13545 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13546 });
13547 let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13548 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13549 });
13550 let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13551 capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13552 });
13553 let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13554 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13555 });
13556 let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13557 capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13558 }) && selected_capabilities.iter().any(|capability| {
13559 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13560 });
13561 let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13562 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13563 });
13564 let include_session_bundles = selected_capabilities.iter().any(|capability| {
13565 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13566 });
13567 let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13568 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13569 });
13570 let include_agent_progress = selected_capabilities.iter().any(|capability| {
13571 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13572 });
13573 let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13574 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13575 });
13576 let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13577 capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13578 });
13579 let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13580 .try_acquire_owned()
13581 .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
13582 let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
13583 let _release_guard = ControllerReleaseGuard {
13584 shared: Arc::clone(&shared),
13585 connection_id,
13586 };
13587 drop(preauth_permit);
13588 let _authenticated_permit = authenticated_permit;
13589 let mut event_rx = shared.event_tx.subscribe();
13590 let mut terminal_event_rx = terminal_event_subscription(
13591 &shared,
13592 include_terminal_frame_events,
13593 );
13594 let mut agent_stream_event_rx = agent_stream_event_subscription(
13595 &shared,
13596 include_agent_stream_events,
13597 );
13598 let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
13599 &shared,
13600 include_harness_mcp_proxy,
13601 );
13602 write_json_frame(
13603 &mut pipe,
13604 &ServerFrame::Hello(NodeHello {
13605 build_stamp: BUILD_STAMP.to_owned(),
13606 incarnation_id: shared.incarnation_id,
13607 connection_id,
13608 role: hello.role,
13609 event_sequence: shared.current_sequence(),
13610 controller: shared.controller_state(),
13611 snapshot: snapshot_for_wire(
13612 &shared,
13613 include_provider_runtime_status,
13614 include_open_provider_ids,
13615 include_spawn_profiles,
13616 include_managed_worktrees,
13617 include_child_environment_profiles,
13618 include_session_bundles,
13619 include_history_context_packs,
13620 include_agent_progress,
13621 include_session_task_correlation,
13622 ),
13623 compatibility,
13624 }),
13625 )
13626 .await?;
13627 let (mut reader, mut writer) = tokio::io::split(pipe);
13628 let (frame_tx, mut frame_rx) = mpsc::channel(16);
13629 let reader_task = tokio::spawn(async move {
13630 loop {
13631 let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
13632 &mut reader,
13633 MAX_NODE_CLIENT_FRAME_BYTES,
13634 Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13635 )
13636 .await;
13637 let terminal = frame.is_err();
13638 if frame_tx.send(frame).await.is_err() || terminal {
13639 break;
13640 }
13641 }
13642 });
13643 let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
13644 let mut pending_events = Vec::new();
13645 let mut resync_required = false;
13646 let mut discard_events_through = 0_u64;
13647 loop {
13648 shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
13653 let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13654 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13655 for _ in 0..durable_drain_budget {
13656 match event_rx.try_recv() {
13657 Ok(event) => queue_connection_event(
13658 &mut pending_events,
13659 event,
13660 discard_events_through,
13661 ),
13662 Err(broadcast::error::TryRecvError::Empty) => break,
13663 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13664 resync_required = true;
13665 break;
13666 }
13667 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13668 }
13669 }
13670 if let Some(receiver) = terminal_event_rx.as_mut() {
13671 match receiver.try_recv() {
13672 Ok(events) => queue_connection_event_batch(
13673 &mut pending_events,
13674 &events,
13675 discard_events_through,
13676 ),
13677 Err(broadcast::error::TryRecvError::Empty) => {}
13678 Err(broadcast::error::TryRecvError::Lagged(_)) => {
13679 resync_required = true;
13680 }
13681 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13682 }
13683 }
13684 if let Some(receiver) = agent_stream_event_rx.as_mut() {
13685 let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13695 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13696 for _ in 0..agent_stream_drain_budget {
13697 match receiver.try_recv() {
13698 Ok(events) => queue_connection_event_batch(
13699 &mut pending_events,
13700 &events,
13701 discard_events_through,
13702 ),
13703 Err(broadcast::error::TryRecvError::Empty) => break,
13704 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13705 tracing::warn!(
13706 connection_id,
13707 dropped,
13708 "agent stream event subscription lagged; forcing a resync",
13709 );
13710 resync_required = true;
13711 break;
13712 }
13713 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13714 }
13715 }
13716 }
13717 if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
13718 let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
13724 .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
13725 for _ in 0..harness_mcp_proxy_drain_budget {
13726 match receiver.try_recv() {
13727 Ok(events) => queue_connection_event_batch(
13728 &mut pending_events,
13729 &events,
13730 discard_events_through,
13731 ),
13732 Err(broadcast::error::TryRecvError::Empty) => break,
13733 Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
13734 tracing::warn!(
13735 connection_id,
13736 dropped,
13737 "harness MCP proxy event subscription lagged; forcing a resync",
13738 );
13739 resync_required = true;
13740 break;
13741 }
13742 Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
13743 }
13744 }
13745 }
13746 if resync_required {
13747 let event = resync_required_event(&shared);
13748 apply_connection_resync_watermark(
13749 &mut pending_events,
13750 &mut discard_events_through,
13751 event.sequence,
13752 );
13753 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13754 resync_required = false;
13755 continue;
13756 }
13757 if !pending_events.is_empty() {
13758 pending_events.sort_unstable_by_key(|event| event.sequence);
13759 let burst_len = connection_event_burst_len(pending_events.len());
13760 shared
13761 .connection_events_sent
13762 .fetch_add(burst_len as u64, Ordering::Relaxed);
13763 for event in pending_events.drain(..burst_len) {
13764 let event = if include_managed_worktrees {
13765 Some(event)
13766 } else {
13767 project_event_without_managed_worktrees(event)
13768 };
13769 let event = event.and_then(|event| if include_open_provider_ids {
13770 Some(event)
13771 } else {
13772 project_event_legacy_provider_ids(&shared, event)
13773 });
13774 let event = event.map(|event| if include_child_environment_profiles {
13775 event
13776 } else {
13777 project_event_without_child_environment_profile(event)
13778 });
13779 let event = event.map(|event| if include_session_bundles {
13780 event
13781 } else {
13782 project_event_without_session_bundle(event)
13783 });
13784 let event = event.map(|event| {
13785 project_event_history_for_wire(event, include_history_context_packs)
13786 });
13787 let event = event.map(|event| if include_history_context_packs {
13788 event
13789 } else {
13790 project_event_without_context_pack(event)
13791 });
13792 let event = event.map(|event| if include_session_task_correlation {
13793 event
13794 } else {
13795 project_event_without_session_task_binding(event)
13796 });
13797 let exact_harness_controller = hello.role == ClientRole::Operator
13798 && shared.controller_state().is_some_and(|controller| {
13799 controller.connection_id == connection_id
13800 });
13801 let event = event.and_then(|event| {
13802 if include_harness_mcp_proxy && exact_harness_controller {
13803 Some(event)
13804 } else {
13805 project_event_without_harness_mcp_proxy(event)
13806 }
13807 });
13808 if let Some(event) = event {
13809 write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
13810 }
13811 }
13812 }
13813 tokio::select! {
13814 biased;
13815 frame = frame_rx.recv() => {
13816 let frame = match frame {
13817 Some(frame) => frame,
13818 None => break,
13819 };
13820 let frame = match frame {
13821 Ok(frame) => frame,
13822 Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
13823 Err(error) => return Err(error.into()),
13824 };
13825 let ClientFrame::Request(request) = frame else {
13826 return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
13827 };
13828 #[cfg(feature = "fixture")]
13829 let probe_spawn_managed_worktree_v2 = matches!(
13830 &request.request,
13831 NodeRequest::SpawnManagedWorktreeV2 { .. }
13832 );
13833 let requires_open_provider_ids =
13834 request_requires_open_provider_ids(&shared, &request.request);
13835 let requires_child_environment_profile =
13836 request_requires_child_environment_profile(&shared, &request.request);
13837 let requires_session_bundle =
13838 request_requires_session_bundle(&shared, &request.request);
13839 let requires_history_context_pack =
13840 request_requires_history_context_pack(&shared, &request.request);
13841 let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
13842 ResponseEnvelope {
13843 request_id: request.request_id,
13844 result: Err(failure(
13845 NodeFailureCode::UnsupportedCapability,
13846 "open provider IDs were not negotiated",
13847 )),
13848 }
13849 } else if requires_child_environment_profile
13850 && !include_child_environment_profiles
13851 {
13852 ResponseEnvelope {
13853 request_id: request.request_id,
13854 result: Err(failure(
13855 NodeFailureCode::UnsupportedCapability,
13856 "child environment profiles were not negotiated",
13857 )),
13858 }
13859 } else if requires_session_bundle && !include_session_bundles {
13860 ResponseEnvelope {
13861 request_id: request.request_id,
13862 result: Err(failure(
13863 NodeFailureCode::UnsupportedCapability,
13864 "session bundles were not negotiated",
13865 )),
13866 }
13867 } else if requires_history_context_pack && !include_history_context_packs {
13868 ResponseEnvelope {
13869 request_id: request.request_id,
13870 result: Err(failure(
13871 NodeFailureCode::UnsupportedCapability,
13872 "history context packs were not negotiated",
13873 )),
13874 }
13875 } else if request_uses_unnegotiated_capability(
13876 &request.request,
13877 &selected_capabilities,
13878 )
13879 {
13880 ResponseEnvelope {
13881 request_id: request.request_id,
13882 result: Err(failure(
13883 NodeFailureCode::UnsupportedCapability,
13884 "request capability was not negotiated",
13885 )),
13886 }
13887 } else {
13888 process_request(&shared, connection_id, hello.role, request).await
13889 };
13890 #[cfg(feature = "fixture")]
13891 if probe_spawn_managed_worktree_v2 {
13892 shared
13893 .fixture_spawn_managed_worktree_v2_failure_probe
13894 .record(reply.result.as_ref().err().map(|failure| failure.code));
13895 }
13896 if !include_provider_runtime_status {
13897 clear_response_provider_runtime_status(&mut reply);
13898 }
13899 if !include_managed_worktrees {
13900 project_response_without_managed_worktrees(&mut reply);
13901 }
13902 if !include_open_provider_ids {
13903 project_response_legacy_provider_ids(&shared, &mut reply);
13904 }
13905 if !include_child_environment_profiles {
13906 project_response_without_child_environment_profile(&mut reply);
13907 }
13908 if !include_session_bundles {
13909 project_response_without_session_bundle(&mut reply);
13910 }
13911 project_response_history_for_wire(
13912 &mut reply,
13913 include_history_context_packs,
13914 );
13915 if !include_history_context_packs {
13916 project_response_without_context_pack(&mut reply);
13917 }
13918 if !include_agent_progress {
13919 project_response_without_agent_progress(&mut reply);
13920 }
13921 if !include_session_task_correlation {
13922 project_response_without_session_task_binding(&mut reply);
13923 }
13924 write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
13925 }
13926 _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
13927 event = event_rx.recv() => {
13928 match event {
13929 Ok(event) => queue_connection_event(
13930 &mut pending_events,
13931 event,
13932 discard_events_through,
13933 ),
13934 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13935 Err(broadcast::error::RecvError::Closed) => break,
13936 }
13937 }
13938 events = receive_terminal_event_batch(&mut terminal_event_rx) => {
13939 match events {
13940 Ok(events) => queue_connection_event_batch(
13941 &mut pending_events,
13942 &events,
13943 discard_events_through,
13944 ),
13945 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13946 Err(broadcast::error::RecvError::Closed) => break,
13947 }
13948 }
13949 events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
13950 match events {
13951 Ok(events) => queue_connection_event_batch(
13952 &mut pending_events,
13953 &events,
13954 discard_events_through,
13955 ),
13956 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13957 Err(broadcast::error::RecvError::Closed) => break,
13958 }
13959 }
13960 events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
13961 match events {
13962 Ok(events) => queue_connection_event_batch(
13963 &mut pending_events,
13964 &events,
13965 discard_events_through,
13966 ),
13967 Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
13968 Err(broadcast::error::RecvError::Closed) => break,
13969 }
13970 }
13971 _ = shared.shutdown_notify.notified() => break,
13972 }
13973 }
13974 Ok(())
13975}
13976
13977async fn receive_terminal_event_batch(
13978 receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
13979) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
13980 match receiver {
13981 Some(receiver) => receiver.recv().await,
13982 None => std::future::pending().await,
13983 }
13984}
13985
13986fn terminal_event_subscription(
13987 shared: &NodeShared,
13988 enabled: bool,
13989) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
13990 enabled.then(|| shared.terminal_event_tx.subscribe())
13991}
13992
13993fn agent_stream_event_subscription(
13997 shared: &NodeShared,
13998 enabled: bool,
13999) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14000 enabled.then(|| shared.agent_stream_event_tx.subscribe())
14001}
14002
14003fn harness_mcp_proxy_event_subscription(
14008 shared: &NodeShared,
14009 enabled: bool,
14010) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14011 enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14012}
14013
14014fn queue_connection_event(
14015 pending: &mut Vec<NodeEventEnvelope>,
14016 event: NodeEventEnvelope,
14017 discard_through: u64,
14018) {
14019 if event.sequence <= discard_through
14020 && !event.event.requires_harness_mcp_proxy_capability()
14021 {
14022 return;
14023 }
14024 if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14025 if let Some(index) = pending.iter().position(|current| {
14026 matches!(
14027 ¤t.event,
14028 NodeEvent::TerminalFrame {
14029 address: current_address,
14030 ..
14031 } if current_address == address
14032 )
14033 }) {
14034 pending.remove(index);
14035 }
14036 }
14037 pending.push(event);
14038}
14039
14040fn project_event_without_harness_mcp_proxy(
14041 envelope: NodeEventEnvelope,
14042) -> Option<NodeEventEnvelope> {
14043 (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14044}
14045
14046fn queue_connection_event_batch(
14047 pending: &mut Vec<NodeEventEnvelope>,
14048 events: &[NodeEventEnvelope],
14049 discard_through: u64,
14050) {
14051 for event in events {
14052 queue_connection_event(pending, event.clone(), discard_through);
14053 }
14054}
14055
14056fn apply_connection_resync_watermark(
14057 pending: &mut Vec<NodeEventEnvelope>,
14058 discard_through: &mut u64,
14059 marker_sequence: u64,
14060) {
14061 *discard_through = (*discard_through).max(marker_sequence);
14062 pending.retain(|event| event.sequence > *discard_through);
14063}
14064
14065fn connection_event_burst_len(pending_len: usize) -> usize {
14066 pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14067}
14068
14069fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14070 let history = shared
14071 .history
14072 .lock()
14073 .unwrap_or_else(|poisoned| poisoned.into_inner());
14074 let sequence = history.last_sequence;
14075 let oldest_available_sequence = history.replay_floor_sequence;
14076 NodeEventEnvelope {
14077 sequence,
14078 event: NodeEvent::ResyncRequired {
14079 oldest_available_sequence,
14080 },
14081 }
14082}
14083
14084fn node_compatibility_support(
14085 shared: &NodeShared,
14086) -> Result<NodeCompatibilitySupport, NodeServerError> {
14087 let mut support = node_compatibility_support_for_manifest(
14088 &shared.provider_contracts,
14089 &shared.provider_adapter_contracts,
14090 )?;
14091 if shared
14092 .delivery_store
14093 .lock()
14094 .unwrap_or_else(|poisoned| poisoned.into_inner())
14095 .is_some()
14096 {
14097 support.capabilities.push(
14098 CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14099 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14100 );
14101 }
14102 if shared.harness_mcp_registry.is_some() {
14103 support.capabilities.push(
14104 CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14105 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14106 );
14107 }
14108 Ok(support)
14109}
14110
14111fn node_compatibility_support_for_manifest(
14112 provider_contracts: &[ProviderContractSupport],
14113 provider_adapter_contracts: &[ProviderAdapterContractSupport],
14114) -> Result<NodeCompatibilitySupport, NodeServerError> {
14115 Ok(NodeCompatibilitySupport {
14116 build_stamp: BUILD_STAMP.to_owned(),
14117 capabilities: baseline_capabilities()?,
14118 host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14119 path_semantics: platform::path_semantics(),
14120 local_transport: platform::local_transport(),
14121 state_schema: StateSchemaSupport {
14122 versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14123 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14124 },
14125 provider_contracts: provider_contracts.to_vec(),
14126 provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14127 })
14128}
14129
14130fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14131 let capabilities = [
14132 NODE_COMPATIBILITY_METADATA_CAPABILITY,
14133 CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14134 NODE_REPOSITORY_PATH_CAPABILITY,
14135 NODE_WORKSPACE_FILE_READ_CAPABILITY,
14136 NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14137 NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14138 NODE_GIT_READ_CAPABILITY,
14139 NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14140 NODE_PROVIDER_ID_OPEN_CAPABILITY,
14141 NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14142 NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14143 NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14144 NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14145 NODE_ACP_CONTROL_CAPABILITY,
14146 NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14147 NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14148 NODE_WORKTREE_SELECTION_CAPABILITY,
14149 NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14150 NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14151 NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14152 NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14153 NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14154 NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14155 NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14156 NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14157 NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14158 NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14159 NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14160 NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14161 ]
14162 .into_iter()
14163 .map(|capability| {
14164 CapabilityId::new(capability)
14165 .map_err(|error| NodeServerError::Handshake(error.to_string()))
14166 })
14167 .collect::<Result<Vec<_>, _>>()?;
14168 #[cfg(windows)]
14169 let capabilities = {
14170 let mut capabilities = capabilities;
14171 capabilities.push(
14172 CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14173 .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14174 );
14175 capabilities
14176 };
14177 Ok(capabilities)
14178}
14179
14180fn request_uses_unnegotiated_capability(
14181 request: &NodeRequest,
14182 selected_capabilities: &[CapabilityId],
14183) -> bool {
14184 let selected = |required: &str| {
14185 selected_capabilities
14186 .iter()
14187 .any(|capability| capability.as_str() == required)
14188 };
14189 request
14190 .required_capability()
14191 .is_some_and(|required| !selected(required))
14192 || (request.requires_worktree_selection_capability()
14193 && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14194 || (request.requires_spawn_spec_defaults_overrides_capability()
14195 && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14196 || (request.requires_spawn_profile_revision_capability()
14197 && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14198 || (request.requires_child_environment_profile_capability()
14199 && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14200 || (request.requires_session_bundle_materialization_capability()
14201 && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14202 || (request.requires_history_context_pack_capability()
14203 && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14204}
14205
14206fn snapshot_for_wire(
14207 shared: &NodeShared,
14208 include_provider_runtime_status: bool,
14209 include_open_provider_ids: bool,
14210 include_spawn_profiles: bool,
14211 include_managed_worktrees: bool,
14212 include_child_environment_profiles: bool,
14213 include_session_bundles: bool,
14214 include_history_context_packs: bool,
14215 include_agent_progress: bool,
14216 include_session_task_correlation: bool,
14217) -> NodeSnapshot {
14218 let mut snapshot = shared.snapshot();
14219 if !include_provider_runtime_status {
14220 snapshot.provider_runtime_statuses.clear();
14221 }
14222 if !include_open_provider_ids {
14223 project_snapshot_legacy_provider_ids(&mut snapshot);
14224 }
14225 if !include_managed_worktrees {
14226 snapshot.managed_worktrees.clear();
14227 for workspace in &mut snapshot.workspaces {
14228 workspace.managed_worktree_profiles = None;
14229 }
14230 }
14231 if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14232 if !include_spawn_profiles {
14233 inventory.spawn_profiles = None;
14234 }
14235 if !include_session_bundles {
14236 inventory.bundles = None;
14237 }
14238 if inventory.spawn_profiles.is_none()
14239 && inventory.bundles.is_none()
14240 && inventory.network_allowlists.is_none()
14241 {
14242 snapshot.launch_inventory = None;
14243 }
14244 }
14245 if !include_child_environment_profiles {
14246 clear_snapshot_child_environment_profiles(&mut snapshot);
14247 }
14248 if !include_session_bundles {
14249 clear_snapshot_session_bundles(&mut snapshot);
14250 }
14251 project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14252 if !include_history_context_packs {
14253 clear_snapshot_context_packs(&mut snapshot);
14254 }
14255 if !include_agent_progress {
14256 snapshot.agent_progress.clear();
14257 }
14258 if !include_session_task_correlation {
14259 clear_snapshot_session_task_bindings(&mut snapshot);
14260 }
14261 snapshot
14262}
14263
14264fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14265 for record in &mut snapshot.session_records {
14266 record.task_binding = None;
14267 }
14268}
14269
14270fn project_event_without_session_task_binding(
14271 mut envelope: NodeEventEnvelope,
14272) -> NodeEventEnvelope {
14273 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14274 record.task_binding = None;
14275 }
14276 envelope
14277}
14278
14279fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14280 let Ok(response) = reply.result.as_mut() else { return };
14281 match response {
14282 NodeResponse::Snapshot { snapshot, .. } => {
14283 clear_snapshot_session_task_bindings(snapshot);
14284 }
14285 NodeResponse::Resync { snapshot, events, .. } => {
14286 clear_snapshot_session_task_bindings(snapshot);
14287 for event in events {
14288 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14289 record.task_binding = None;
14290 }
14291 }
14292 }
14293 NodeResponse::SessionRecordUpdated { record }
14294 | NodeResponse::ProviderSessionIndexed { record }
14295 | NodeResponse::NativeSessionIndexed { record, .. }
14296 | NodeResponse::SessionRecordResumed { record, .. } => {
14297 record.task_binding = None;
14298 }
14299 _ => {}
14300 }
14301}
14302
14303fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14304 let Ok(response) = reply.result.as_mut() else { return };
14305 match response {
14306 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14307 snapshot.agent_progress.clear();
14308 }
14309 _ => {}
14310 }
14311}
14312
14313fn project_event_without_managed_worktrees(
14314 mut envelope: NodeEventEnvelope,
14315) -> Option<NodeEventEnvelope> {
14316 if matches!(
14317 envelope.event,
14318 NodeEvent::ManagedWorktreeUpserted { .. }
14319 | NodeEvent::ManagedWorktreeRemoved { .. }
14320 ) {
14321 return None;
14322 }
14323 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14324 workspace.managed_worktree_profiles = None;
14325 }
14326 Some(envelope)
14327}
14328
14329fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14330 if matches!(
14331 reply.result,
14332 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14333 | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14334 ) {
14335 reply.result = Err(failure(
14336 NodeFailureCode::UnsupportedCapability,
14337 "managed worktree capability was not negotiated",
14338 ));
14339 return;
14340 }
14341 let Ok(response) = reply.result.as_mut() else { return };
14342 match response {
14343 NodeResponse::Snapshot { snapshot, .. } => {
14344 snapshot.managed_worktrees.clear();
14345 for workspace in &mut snapshot.workspaces {
14346 workspace.managed_worktree_profiles = None;
14347 }
14348 }
14349 NodeResponse::Resync { snapshot, events, .. } => {
14350 snapshot.managed_worktrees.clear();
14351 for workspace in &mut snapshot.workspaces {
14352 workspace.managed_worktree_profiles = None;
14353 }
14354 *events = std::mem::take(events)
14355 .into_iter()
14356 .filter_map(project_event_without_managed_worktrees)
14357 .collect();
14358 }
14359 NodeResponse::WorkspaceRegistered { workspace }
14360 | NodeResponse::StandaloneWorkspaceCreated { workspace }
14361 | NodeResponse::WorktreeCreated { workspace, .. } => {
14362 workspace.managed_worktree_profiles = None;
14363 }
14364 NodeResponse::WorkspaceInspected { inspection } => {
14365 inspection.git.managed_worktree = None;
14366 }
14367 _ => {}
14368 }
14369}
14370
14371fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14372 let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14373 capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14374 });
14375 if includes_open_provider_ids {
14376 return;
14377 }
14378 compatibility
14379 .provider_contracts
14380 .retain(|contract| provider_id_is_legacy(&contract.provider));
14381 compatibility
14382 .provider_adapter_contracts
14383 .retain(|contract| provider_id_is_legacy(&contract.provider));
14384}
14385
14386fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14387 if let Some(spec) = match request {
14388 NodeRequest::SpawnSpec { spec }
14389 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14390 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14391 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14392 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14393 _ => None,
14394 } {
14395 return shared
14396 .resolve_spawn_spec(spec)
14397 .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14398 }
14399 request_requires_open_provider_ids_with(
14400 request,
14401 |session| shared.validate_address(session).ok(),
14402 |record_id| shared.record(record_id).ok().map(|record| record.provider),
14403 )
14404}
14405
14406fn request_requires_child_environment_profile(
14407 shared: &NodeShared,
14408 request: &NodeRequest,
14409) -> bool {
14410 if let Some(spec) = match request {
14411 NodeRequest::SpawnSpec { spec }
14412 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14413 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14414 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14415 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14416 _ => None,
14417 } {
14418 return shared
14419 .resolve_spawn_spec(spec)
14420 .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14421 }
14422 match request {
14423 NodeRequest::Resume { session, .. }
14424 | NodeRequest::Prompt { session, .. }
14425 | NodeRequest::Paste { session, .. }
14426 | NodeRequest::Input { session, .. }
14427 | NodeRequest::TerminalBytes { session, .. }
14428 | NodeRequest::TerminalControl { session, .. }
14429 | NodeRequest::Resize { session, .. }
14430 | NodeRequest::Interrupt { session }
14431 | NodeRequest::Stop { session, .. }
14432 | NodeRequest::Remove { session }
14433 | NodeRequest::ResolveInteraction { session, .. }
14434 | NodeRequest::SetSessionMode { session, .. }
14435 | NodeRequest::SetSessionConfigOption { session, .. }
14436 | NodeRequest::SetSessionModel { session, .. }
14437 | NodeRequest::DiscoverHistory { session, .. }
14438 | NodeRequest::LoadHistory { session, .. }
14439 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14440 | NodeRequest::ExportContextPack { session } => shared
14441 .session_bindings
14442 .lock()
14443 .unwrap_or_else(|poisoned| poisoned.into_inner())
14444 .get(&session.session.instance_id)
14445 .filter(|binding| {
14446 binding.workspace_id == session.workspace_id
14447 && binding.generation == session.session.generation
14448 })
14449 .map_or(true, |binding| binding.environment_profile.is_some()),
14450 NodeRequest::RenameSessionRecord { record_id, .. }
14451 | NodeRequest::SetSessionTask { record_id, .. }
14452 | NodeRequest::ResumeSessionRecord { record_id, .. }
14453 | NodeRequest::ForgetSessionRecord { record_id } => shared
14454 .record(record_id)
14455 .map_or(true, |record| record.environment_profile.is_some()),
14456 NodeRequest::ArmHarnessMcpReservation { .. }
14457 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14458 | NodeRequest::ActivateHarnessMcpReservation { .. }
14459 | NodeRequest::AbortHarnessMcpReservation { .. }
14460 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14461 | NodeRequest::RejectHarnessMcpCall { .. }
14462 | NodeRequest::Snapshot
14463 | NodeRequest::Resync { .. }
14464 | NodeRequest::BeginDeliveryStage { .. }
14465 | NodeRequest::PutDeliveryBlobChunk { .. }
14466 | NodeRequest::CommitDeliveryStage { .. }
14467 | NodeRequest::AbortDeliveryStage { .. }
14468 | NodeRequest::BrowseHostDirectories { .. }
14469 | NodeRequest::InspectWorkspace { .. }
14470 | NodeRequest::ReadWorkspaceFile { .. }
14471 | NodeRequest::WriteWorkspaceFile { .. }
14472 | NodeRequest::CreateWorkspaceFile { .. }
14473 | NodeRequest::CreateWorkspaceDirectory { .. }
14474 | NodeRequest::ReadGitHistory { .. }
14475 | NodeRequest::ReadGitDiff { .. }
14476 | NodeRequest::AcquireController { .. }
14477 | NodeRequest::ReleaseController
14478 | NodeRequest::Spawn { .. }
14479 | NodeRequest::SpawnWithOptions { .. }
14480 | NodeRequest::SpawnSpec { .. }
14481 | NodeRequest::RegisterWorkspace { .. }
14482 | NodeRequest::CreateStandaloneWorkspace { .. }
14483 | NodeRequest::UnregisterWorkspace { .. }
14484 | NodeRequest::CreateWorktree { .. }
14485 | NodeRequest::RemoveWorktree { .. }
14486 | NodeRequest::SpawnManagedWorktree { .. }
14487 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14488 | NodeRequest::CleanupManagedWorktree { .. }
14489 | NodeRequest::IndexProviderSession { .. }
14490 | NodeRequest::IndexNativeSession { .. }
14491 | NodeRequest::CatalogNativeSessions { .. }
14492 | NodeRequest::PageNativeSessions { .. }
14493 | NodeRequest::PreviewNativeSession { .. }
14494 | NodeRequest::PreviewSessionRecord { .. }
14495 | NodeRequest::ForgetContextPack { .. }
14496 | NodeRequest::ResolveDurableContextPack { .. }
14497 | NodeRequest::ReadContextPack { .. }
14498 | NodeRequest::Shutdown => false,
14499 }
14500}
14501
14502fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14503 if let Some(spec) = match request {
14504 NodeRequest::SpawnSpec { spec }
14505 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14506 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14507 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14508 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14509 _ => None,
14510 } {
14511 return shared
14512 .resolve_spawn_spec(spec)
14513 .is_ok_and(|resolved| resolved.bundle_id.is_some());
14514 }
14515 match request {
14516 NodeRequest::Resume { session, .. }
14517 | NodeRequest::Prompt { session, .. }
14518 | NodeRequest::Paste { session, .. }
14519 | NodeRequest::Input { session, .. }
14520 | NodeRequest::TerminalBytes { session, .. }
14521 | NodeRequest::TerminalControl { session, .. }
14522 | NodeRequest::Resize { session, .. }
14523 | NodeRequest::Interrupt { session }
14524 | NodeRequest::Stop { session, .. }
14525 | NodeRequest::Remove { session }
14526 | NodeRequest::ResolveInteraction { session, .. }
14527 | NodeRequest::SetSessionMode { session, .. }
14528 | NodeRequest::SetSessionConfigOption { session, .. }
14529 | NodeRequest::SetSessionModel { session, .. }
14530 | NodeRequest::DiscoverHistory { session, .. }
14531 | NodeRequest::LoadHistory { session, .. }
14532 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14533 | NodeRequest::ExportContextPack { session } => shared
14534 .session_bindings
14535 .lock()
14536 .unwrap_or_else(|poisoned| poisoned.into_inner())
14537 .get(&session.session.instance_id)
14538 .filter(|binding| {
14539 binding.workspace_id == session.workspace_id
14540 && binding.generation == session.session.generation
14541 })
14542 .map_or(true, |binding| binding.bundle.is_some()),
14543 NodeRequest::RenameSessionRecord { record_id, .. }
14544 | NodeRequest::SetSessionTask { record_id, .. }
14545 | NodeRequest::ResumeSessionRecord { record_id, .. }
14546 | NodeRequest::ForgetSessionRecord { record_id } => shared
14547 .record(record_id)
14548 .map_or(true, |record| record.bundle.is_some()),
14549 NodeRequest::ArmHarnessMcpReservation { .. }
14550 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14551 | NodeRequest::ActivateHarnessMcpReservation { .. }
14552 | NodeRequest::AbortHarnessMcpReservation { .. }
14553 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14554 | NodeRequest::RejectHarnessMcpCall { .. }
14555 | NodeRequest::Snapshot
14556 | NodeRequest::Resync { .. }
14557 | NodeRequest::BeginDeliveryStage { .. }
14558 | NodeRequest::PutDeliveryBlobChunk { .. }
14559 | NodeRequest::CommitDeliveryStage { .. }
14560 | NodeRequest::AbortDeliveryStage { .. }
14561 | NodeRequest::BrowseHostDirectories { .. }
14562 | NodeRequest::InspectWorkspace { .. }
14563 | NodeRequest::ReadWorkspaceFile { .. }
14564 | NodeRequest::WriteWorkspaceFile { .. }
14565 | NodeRequest::CreateWorkspaceFile { .. }
14566 | NodeRequest::CreateWorkspaceDirectory { .. }
14567 | NodeRequest::ReadGitHistory { .. }
14568 | NodeRequest::ReadGitDiff { .. }
14569 | NodeRequest::AcquireController { .. }
14570 | NodeRequest::ReleaseController
14571 | NodeRequest::Spawn { .. }
14572 | NodeRequest::SpawnWithOptions { .. }
14573 | NodeRequest::SpawnSpec { .. }
14574 | NodeRequest::RegisterWorkspace { .. }
14575 | NodeRequest::CreateStandaloneWorkspace { .. }
14576 | NodeRequest::UnregisterWorkspace { .. }
14577 | NodeRequest::CreateWorktree { .. }
14578 | NodeRequest::RemoveWorktree { .. }
14579 | NodeRequest::SpawnManagedWorktree { .. }
14580 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14581 | NodeRequest::CleanupManagedWorktree { .. }
14582 | NodeRequest::IndexProviderSession { .. }
14583 | NodeRequest::IndexNativeSession { .. }
14584 | NodeRequest::CatalogNativeSessions { .. }
14585 | NodeRequest::PageNativeSessions { .. }
14586 | NodeRequest::PreviewNativeSession { .. }
14587 | NodeRequest::PreviewSessionRecord { .. }
14588 | NodeRequest::ForgetContextPack { .. }
14589 | NodeRequest::ResolveDurableContextPack { .. }
14590 | NodeRequest::ReadContextPack { .. }
14591 | NodeRequest::Shutdown => false,
14592 }
14593}
14594
14595fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
14596 if request.requires_history_context_pack_capability() {
14597 return true;
14598 }
14599 if let Some(spec) = match request {
14600 NodeRequest::SpawnSpec { spec }
14601 | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14602 | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14603 NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14604 NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14605 _ => None,
14606 } {
14607 return shared
14608 .resolve_spawn_spec(spec)
14609 .map_or(true, |resolved| resolved.context_id.is_some());
14610 }
14611 match request {
14612 NodeRequest::Resume { session, .. }
14613 | NodeRequest::Prompt { session, .. }
14614 | NodeRequest::Paste { session, .. }
14615 | NodeRequest::Input { session, .. }
14616 | NodeRequest::TerminalBytes { session, .. }
14617 | NodeRequest::TerminalControl { session, .. }
14618 | NodeRequest::Resize { session, .. }
14619 | NodeRequest::Interrupt { session }
14620 | NodeRequest::Stop { session, .. }
14621 | NodeRequest::Remove { session }
14622 | NodeRequest::ResolveInteraction { session, .. }
14623 | NodeRequest::SetSessionMode { session, .. }
14624 | NodeRequest::SetSessionConfigOption { session, .. }
14625 | NodeRequest::SetSessionModel { session, .. } => shared
14626 .session_bindings
14627 .lock()
14628 .unwrap_or_else(|poisoned| poisoned.into_inner())
14629 .get(&session.session.instance_id)
14630 .filter(|binding| {
14631 binding.workspace_id == session.workspace_id
14632 && binding.generation == session.session.generation
14633 })
14634 .map_or(true, |binding| binding.context.is_some()),
14635 NodeRequest::RenameSessionRecord { record_id, .. }
14636 | NodeRequest::SetSessionTask { record_id, .. }
14637 | NodeRequest::ResumeSessionRecord { record_id, .. }
14638 | NodeRequest::ForgetSessionRecord { record_id } => shared
14639 .record(record_id)
14640 .map_or(true, |record| record.context.is_some()),
14641 NodeRequest::ArmHarnessMcpReservation { .. }
14642 | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14643 | NodeRequest::ActivateHarnessMcpReservation { .. }
14644 | NodeRequest::AbortHarnessMcpReservation { .. }
14645 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14646 | NodeRequest::RejectHarnessMcpCall { .. }
14647 | NodeRequest::Snapshot
14648 | NodeRequest::Resync { .. }
14649 | NodeRequest::BeginDeliveryStage { .. }
14650 | NodeRequest::PutDeliveryBlobChunk { .. }
14651 | NodeRequest::CommitDeliveryStage { .. }
14652 | NodeRequest::AbortDeliveryStage { .. }
14653 | NodeRequest::BrowseHostDirectories { .. }
14654 | NodeRequest::InspectWorkspace { .. }
14655 | NodeRequest::ReadWorkspaceFile { .. }
14656 | NodeRequest::WriteWorkspaceFile { .. }
14657 | NodeRequest::CreateWorkspaceFile { .. }
14658 | NodeRequest::CreateWorkspaceDirectory { .. }
14659 | NodeRequest::ReadGitHistory { .. }
14660 | NodeRequest::ReadGitDiff { .. }
14661 | NodeRequest::AcquireController { .. }
14662 | NodeRequest::ReleaseController
14663 | NodeRequest::Spawn { .. }
14664 | NodeRequest::SpawnWithOptions { .. }
14665 | NodeRequest::SpawnSpec { .. }
14666 | NodeRequest::RegisterWorkspace { .. }
14667 | NodeRequest::CreateStandaloneWorkspace { .. }
14668 | NodeRequest::UnregisterWorkspace { .. }
14669 | NodeRequest::CreateWorktree { .. }
14670 | NodeRequest::RemoveWorktree { .. }
14671 | NodeRequest::SpawnManagedWorktree { .. }
14672 | NodeRequest::SpawnManagedWorktreeV2 { .. }
14673 | NodeRequest::CleanupManagedWorktree { .. }
14674 | NodeRequest::IndexProviderSession { .. }
14675 | NodeRequest::IndexNativeSession { .. }
14676 | NodeRequest::CatalogNativeSessions { .. }
14677 | NodeRequest::PageNativeSessions { .. }
14678 | NodeRequest::PreviewNativeSession { .. }
14679 | NodeRequest::PreviewSessionRecord { .. }
14680 | NodeRequest::DiscoverHistory { .. }
14681 | NodeRequest::LoadHistory { .. }
14682 | NodeRequest::ExportContextPackForSessionRecord { .. }
14683 | NodeRequest::ExportContextPack { .. }
14684 | NodeRequest::ForgetContextPack { .. }
14685 | NodeRequest::ResolveDurableContextPack { .. }
14686 | NodeRequest::ReadContextPack { .. }
14687 | NodeRequest::Shutdown => false,
14688 }
14689}
14690
14691fn request_requires_open_provider_ids_with(
14692 request: &NodeRequest,
14693 provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
14694 provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
14695) -> bool {
14696 match request {
14697 NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
14698 NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
14699 NodeRequest::CatalogNativeSessions { route, .. }
14700 | NodeRequest::PageNativeSessions { route, .. } => {
14701 !provider_id_is_legacy(&route.provider)
14702 }
14703 NodeRequest::PreviewNativeSession { selection, .. }
14704 | NodeRequest::IndexNativeSession { selection, .. } => {
14705 !provider_id_is_legacy(&selection.route.provider)
14706 }
14707 NodeRequest::SpawnSpec { .. }
14708 | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
14709 NodeRequest::SpawnManagedWorktree { .. }
14710 | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
14711 NodeRequest::Resume { session, .. }
14712 | NodeRequest::Prompt { session, .. }
14713 | NodeRequest::Paste { session, .. }
14714 | NodeRequest::Input { session, .. }
14715 | NodeRequest::TerminalBytes { session, .. }
14716 | NodeRequest::TerminalControl { session, .. }
14717 | NodeRequest::Resize { session, .. }
14718 | NodeRequest::Interrupt { session }
14719 | NodeRequest::Stop { session, .. }
14720 | NodeRequest::Remove { session }
14721 | NodeRequest::ResolveInteraction { session, .. }
14722 | NodeRequest::SetSessionMode { session, .. }
14723 | NodeRequest::SetSessionConfigOption { session, .. }
14724 | NodeRequest::SetSessionModel { session, .. }
14725 | NodeRequest::DiscoverHistory { session, .. }
14726 | NodeRequest::LoadHistory { session, .. }
14727 | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14728 | NodeRequest::ExportContextPack { session } => provider_for_session(session)
14729 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14730 NodeRequest::RenameSessionRecord { record_id, .. }
14731 | NodeRequest::SetSessionTask { record_id, .. }
14732 | NodeRequest::ResumeSessionRecord { record_id, .. }
14733 | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
14734 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14735 NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
14736 .map_or(true, |provider| !provider_id_is_legacy(&provider)),
14737 NodeRequest::ArmHarnessMcpReservation { .. }
14738 | NodeRequest::ActivateHarnessMcpReservation { .. }
14739 | NodeRequest::AbortHarnessMcpReservation { .. }
14740 | NodeRequest::PutHarnessMcpReplyChunk { .. }
14741 | NodeRequest::RejectHarnessMcpCall { .. }
14742 | NodeRequest::Snapshot
14743 | NodeRequest::Resync { .. }
14744 | NodeRequest::BeginDeliveryStage { .. }
14745 | NodeRequest::PutDeliveryBlobChunk { .. }
14746 | NodeRequest::CommitDeliveryStage { .. }
14747 | NodeRequest::AbortDeliveryStage { .. }
14748 | NodeRequest::BrowseHostDirectories { .. }
14749 | NodeRequest::InspectWorkspace { .. }
14750 | NodeRequest::ReadWorkspaceFile { .. }
14751 | NodeRequest::WriteWorkspaceFile { .. }
14752 | NodeRequest::CreateWorkspaceFile { .. }
14753 | NodeRequest::CreateWorkspaceDirectory { .. }
14754 | NodeRequest::ReadGitHistory { .. }
14755 | NodeRequest::ReadGitDiff { .. }
14756 | NodeRequest::AcquireController { .. }
14757 | NodeRequest::ReleaseController
14758 | NodeRequest::RegisterWorkspace { .. }
14759 | NodeRequest::CreateStandaloneWorkspace { .. }
14760 | NodeRequest::UnregisterWorkspace { .. }
14761 | NodeRequest::CreateWorktree { .. }
14762 | NodeRequest::RemoveWorktree { .. }
14763 | NodeRequest::CleanupManagedWorktree { .. }
14764 | NodeRequest::Shutdown => false,
14765 NodeRequest::ForgetContextPack { .. } => true,
14766 NodeRequest::ResolveDurableContextPack { .. }
14772 | NodeRequest::ReadContextPack { .. } => true,
14773 }
14774}
14775
14776fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
14777 snapshot.enabled_providers.retain(provider_id_is_legacy);
14778 snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
14779 snapshot
14780 .provider_runtime_statuses
14781 .as_slice()
14782 .iter()
14783 .filter(|status| provider_id_is_legacy(status.provider()))
14784 .cloned(),
14785 )
14786 .expect("a filtered provider runtime projection remains valid");
14787 snapshot
14788 .session_records
14789 .retain(|record| provider_id_is_legacy(&record.provider));
14790 for workspace in &mut snapshot.workspaces {
14791 workspace
14792 .sessions
14793 .retain(|session| provider_id_is_legacy(&session.agent_id));
14794 }
14795}
14796
14797fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
14798 for record in &mut snapshot.session_records {
14799 record.environment_profile = None;
14800 }
14801 if let Some(profiles) = snapshot
14802 .launch_inventory
14803 .as_mut()
14804 .and_then(|inventory| inventory.spawn_profiles.as_mut())
14805 {
14806 for profile in profiles {
14807 profile.environment_profile = None;
14808 }
14809 }
14810}
14811
14812fn project_event_without_child_environment_profile(
14813 mut envelope: NodeEventEnvelope,
14814) -> NodeEventEnvelope {
14815 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14816 record.environment_profile = None;
14817 }
14818 envelope
14819}
14820
14821fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
14822 let contains_environment_profile = match reply.result.as_ref() {
14823 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
14824 receipt.environment_profile.is_some()
14825 }
14826 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14827 receipt.spawn.environment_profile.is_some()
14828 }
14829 _ => false,
14830 };
14831 if contains_environment_profile {
14832 reply.result = Err(failure(
14833 NodeFailureCode::UnsupportedCapability,
14834 "child environment profiles were not negotiated",
14835 ));
14836 return;
14837 }
14838 let Ok(response) = reply.result.as_mut() else {
14839 return;
14840 };
14841 match response {
14842 NodeResponse::Snapshot { snapshot, .. } => {
14843 clear_snapshot_child_environment_profiles(snapshot);
14844 }
14845 NodeResponse::Resync { snapshot, events, .. } => {
14846 clear_snapshot_child_environment_profiles(snapshot);
14847 for event in events {
14848 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14849 record.environment_profile = None;
14850 }
14851 }
14852 }
14853 NodeResponse::SessionRecordUpdated { record }
14854 | NodeResponse::ProviderSessionIndexed { record }
14855 | NodeResponse::NativeSessionIndexed { record, .. }
14856 | NodeResponse::SessionRecordResumed { record, .. } => {
14857 record.environment_profile = None;
14858 }
14859 NodeResponse::Armed { .. }
14860 | NodeResponse::Spawned { .. }
14861 | NodeResponse::Activated { .. }
14862 | NodeResponse::Aborted { .. }
14863 | NodeResponse::ReplyChunkAccepted { .. }
14864 | NodeResponse::CallRejected { .. }
14865 | NodeResponse::DeliveryStageBegun { .. }
14866 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14867 | NodeResponse::DeliveryCommitted { .. }
14868 | NodeResponse::DeliveryStageAborted { .. }
14869 | NodeResponse::WorkspaceInspected { .. }
14870 | NodeResponse::HostDirectoriesBrowsed { .. }
14871 | NodeResponse::WorkspaceFileRead { .. }
14872 | NodeResponse::WorkspaceFileWritten { .. }
14873 | NodeResponse::WorkspaceFileCreated { .. }
14874 | NodeResponse::WorkspaceDirectoryCreated { .. }
14875 | NodeResponse::GitHistoryRead { .. }
14876 | NodeResponse::GitDiffRead { .. }
14877 | NodeResponse::Controller { .. }
14878 | NodeResponse::SpawnAccepted { .. }
14879 | NodeResponse::SpawnSpecAccepted { .. }
14880 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14881 | NodeResponse::ManagedWorktreeCleanup { .. }
14882 | NodeResponse::NativeSessionsCataloged { .. }
14883 | NodeResponse::NativeSessionsPaged { .. }
14884 | NodeResponse::NativeSessionPreviewed { .. }
14885 | NodeResponse::SessionRecordPreviewed { .. }
14886 | NodeResponse::HistoryDiscovered { .. }
14887 | NodeResponse::HistoryLoaded { .. }
14888 | NodeResponse::ContextPackForSessionRecordExported { .. }
14889 | NodeResponse::ContextPackExported { .. }
14890 | NodeResponse::ContextPackForgotten { .. }
14891 | NodeResponse::DurableContextPackResolved { .. }
14892 | NodeResponse::ContextPackBytesRead { .. }
14893 | NodeResponse::SessionRecordForgotten { .. }
14894 | NodeResponse::WorkspaceRegistered { .. }
14895 | NodeResponse::StandaloneWorkspaceCreated { .. }
14896 | NodeResponse::WorkspaceUnregistered { .. }
14897 | NodeResponse::WorktreeCreated { .. }
14898 | NodeResponse::WorktreeRemoved { .. }
14899 | NodeResponse::Accepted
14900 | NodeResponse::ShuttingDown => {}
14901 }
14902}
14903
14904fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
14905 for record in &mut snapshot.session_records {
14906 record.bundle = None;
14907 }
14908}
14909
14910fn project_event_without_session_bundle(
14911 mut envelope: NodeEventEnvelope,
14912) -> NodeEventEnvelope {
14913 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14914 record.bundle = None;
14915 }
14916 envelope
14917}
14918
14919fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
14920 let contains_bundle = match reply.result.as_ref() {
14921 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
14922 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
14923 receipt.spawn.bundle.is_some()
14924 }
14925 _ => false,
14926 };
14927 if contains_bundle {
14928 reply.result = Err(failure(
14929 NodeFailureCode::UnsupportedCapability,
14930 "session bundles were not negotiated",
14931 ));
14932 return;
14933 }
14934 let Ok(response) = reply.result.as_mut() else {
14935 return;
14936 };
14937 match response {
14938 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
14939 NodeResponse::Resync { snapshot, events, .. } => {
14940 clear_snapshot_session_bundles(snapshot);
14941 for event in events {
14942 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14943 record.bundle = None;
14944 }
14945 }
14946 }
14947 NodeResponse::SessionRecordUpdated { record }
14948 | NodeResponse::ProviderSessionIndexed { record }
14949 | NodeResponse::NativeSessionIndexed { record, .. }
14950 | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
14951 NodeResponse::Armed { .. }
14952 | NodeResponse::Spawned { .. }
14953 | NodeResponse::Activated { .. }
14954 | NodeResponse::Aborted { .. }
14955 | NodeResponse::ReplyChunkAccepted { .. }
14956 | NodeResponse::CallRejected { .. }
14957 | NodeResponse::DeliveryStageBegun { .. }
14958 | NodeResponse::DeliveryBlobChunkAccepted { .. }
14959 | NodeResponse::DeliveryCommitted { .. }
14960 | NodeResponse::DeliveryStageAborted { .. }
14961 | NodeResponse::WorkspaceInspected { .. }
14962 | NodeResponse::HostDirectoriesBrowsed { .. }
14963 | NodeResponse::WorkspaceFileRead { .. }
14964 | NodeResponse::WorkspaceFileWritten { .. }
14965 | NodeResponse::WorkspaceFileCreated { .. }
14966 | NodeResponse::WorkspaceDirectoryCreated { .. }
14967 | NodeResponse::GitHistoryRead { .. }
14968 | NodeResponse::GitDiffRead { .. }
14969 | NodeResponse::Controller { .. }
14970 | NodeResponse::SpawnAccepted { .. }
14971 | NodeResponse::SpawnSpecAccepted { .. }
14972 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
14973 | NodeResponse::ManagedWorktreeCleanup { .. }
14974 | NodeResponse::NativeSessionsCataloged { .. }
14975 | NodeResponse::NativeSessionsPaged { .. }
14976 | NodeResponse::NativeSessionPreviewed { .. }
14977 | NodeResponse::SessionRecordPreviewed { .. }
14978 | NodeResponse::HistoryDiscovered { .. }
14979 | NodeResponse::HistoryLoaded { .. }
14980 | NodeResponse::ContextPackForSessionRecordExported { .. }
14981 | NodeResponse::ContextPackExported { .. }
14982 | NodeResponse::ContextPackForgotten { .. }
14983 | NodeResponse::DurableContextPackResolved { .. }
14984 | NodeResponse::ContextPackBytesRead { .. }
14985 | NodeResponse::SessionRecordForgotten { .. }
14986 | NodeResponse::WorkspaceRegistered { .. }
14987 | NodeResponse::StandaloneWorkspaceCreated { .. }
14988 | NodeResponse::WorkspaceUnregistered { .. }
14989 | NodeResponse::WorktreeCreated { .. }
14990 | NodeResponse::WorktreeRemoved { .. }
14991 | NodeResponse::Accepted
14992 | NodeResponse::ShuttingDown => {}
14993 }
14994}
14995
14996fn project_snapshot_history_for_wire(
14997 snapshot: &mut NodeSnapshot,
14998 include_history_metadata: bool,
14999) {
15000 for workspace in &mut snapshot.workspaces {
15001 for session in &mut workspace.sessions {
15002 if include_history_metadata {
15003 session.history.loaded = None;
15004 } else {
15005 session.history = Default::default();
15006 }
15007 }
15008 }
15009}
15010
15011fn project_event_history_for_wire(
15012 mut envelope: NodeEventEnvelope,
15013 include_history_metadata: bool,
15014) -> NodeEventEnvelope {
15015 if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15016 for session in &mut workspace.sessions {
15017 if include_history_metadata {
15018 session.history.loaded = None;
15019 } else {
15020 session.history = Default::default();
15021 }
15022 }
15023 }
15024 envelope
15025}
15026
15027fn project_response_history_for_wire(
15028 reply: &mut ResponseEnvelope,
15029 include_history_metadata: bool,
15030) {
15031 let Ok(response) = reply.result.as_mut() else {
15032 return;
15033 };
15034 match response {
15035 NodeResponse::Snapshot { snapshot, .. } => {
15036 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15037 }
15038 NodeResponse::Resync {
15039 snapshot, events, ..
15040 } => {
15041 project_snapshot_history_for_wire(snapshot, include_history_metadata);
15042 for event in events {
15043 *event = project_event_history_for_wire(
15044 event.clone(),
15045 include_history_metadata,
15046 );
15047 }
15048 }
15049 NodeResponse::WorkspaceRegistered { workspace }
15050 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15051 | NodeResponse::WorktreeCreated { workspace, .. } => {
15052 for session in &mut workspace.sessions {
15053 if include_history_metadata {
15054 session.history.loaded = None;
15055 } else {
15056 session.history = Default::default();
15057 }
15058 }
15059 }
15060 _ => {}
15061 }
15062}
15063
15064fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15065 for record in &mut snapshot.session_records {
15066 record.context_id = None;
15067 record.context = None;
15068 record.exported_context = None;
15069 }
15070}
15071
15072fn project_event_without_context_pack(
15073 mut envelope: NodeEventEnvelope,
15074) -> NodeEventEnvelope {
15075 if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15076 record.context_id = None;
15077 record.context = None;
15078 record.exported_context = None;
15079 }
15080 envelope
15081}
15082
15083fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15084 let contains_context = match reply.result.as_ref() {
15085 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15086 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15087 receipt.spawn.context.is_some()
15088 }
15089 Ok(NodeResponse::HistoryDiscovered { .. })
15090 | Ok(NodeResponse::HistoryLoaded { .. })
15091 | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15092 | Ok(NodeResponse::ContextPackExported { .. })
15093 | Ok(NodeResponse::ContextPackForgotten { .. })
15094 | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15095 _ => false,
15096 };
15097 if contains_context {
15098 reply.result = Err(failure(
15099 NodeFailureCode::UnsupportedCapability,
15100 "history context packs were not negotiated",
15101 ));
15102 return;
15103 }
15104 let Ok(response) = reply.result.as_mut() else {
15105 return;
15106 };
15107 match response {
15108 NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15109 NodeResponse::Resync {
15110 snapshot, events, ..
15111 } => {
15112 clear_snapshot_context_packs(snapshot);
15113 for event in events {
15114 if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15115 record.context_id = None;
15116 record.context = None;
15117 record.exported_context = None;
15118 }
15119 }
15120 }
15121 NodeResponse::SessionRecordUpdated { record }
15122 | NodeResponse::ProviderSessionIndexed { record }
15123 | NodeResponse::NativeSessionIndexed { record, .. }
15124 | NodeResponse::SessionRecordResumed { record, .. } => {
15125 record.context_id = None;
15126 record.context = None;
15127 record.exported_context = None;
15128 }
15129 _ => {}
15130 }
15131}
15132
15133fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15134 let contains_open_record = match reply.result.as_ref() {
15135 Ok(NodeResponse::SessionRecordUpdated { record })
15136 | Ok(NodeResponse::ProviderSessionIndexed { record })
15137 | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15138 !provider_id_is_legacy(&record.provider)
15139 }
15140 Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15141 !provider_id_is_legacy(&selection.route.provider)
15142 || !provider_id_is_legacy(&record.provider)
15143 }
15144 Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15145 !provider_id_is_legacy(&receipt.provider)
15146 }
15147 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15148 !provider_id_is_legacy(&receipt.spawn.provider)
15149 }
15150 Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15151 | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15152 !provider_id_is_legacy(&route.provider)
15153 }
15154 Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15155 !provider_id_is_legacy(&selection.route.provider)
15156 }
15157 _ => false,
15158 };
15159 if contains_open_record {
15160 reply.result = Err(failure(
15161 NodeFailureCode::UnsupportedCapability,
15162 "open provider IDs were not negotiated",
15163 ));
15164 return;
15165 }
15166 let Ok(response) = reply.result.as_mut() else {
15167 return;
15168 };
15169 match response {
15170 NodeResponse::Snapshot { snapshot, .. } => {
15171 project_snapshot_legacy_provider_ids(snapshot);
15172 }
15173 NodeResponse::Resync { snapshot, events, .. } => {
15174 project_snapshot_legacy_provider_ids(snapshot);
15175 *events = std::mem::take(events)
15176 .into_iter()
15177 .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15178 .collect();
15179 }
15180 NodeResponse::WorkspaceRegistered { workspace }
15181 | NodeResponse::StandaloneWorkspaceCreated { workspace }
15182 | NodeResponse::WorktreeCreated { workspace, .. } => {
15183 workspace
15184 .sessions
15185 .retain(|session| provider_id_is_legacy(&session.agent_id));
15186 }
15187 NodeResponse::Armed { .. }
15188 | NodeResponse::Spawned { .. }
15189 | NodeResponse::Activated { .. }
15190 | NodeResponse::Aborted { .. }
15191 | NodeResponse::ReplyChunkAccepted { .. }
15192 | NodeResponse::CallRejected { .. }
15193 | NodeResponse::DeliveryStageBegun { .. }
15194 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15195 | NodeResponse::DeliveryCommitted { .. }
15196 | NodeResponse::DeliveryStageAborted { .. }
15197 | NodeResponse::WorkspaceInspected { .. }
15198 | NodeResponse::HostDirectoriesBrowsed { .. }
15199 | NodeResponse::WorkspaceFileRead { .. }
15200 | NodeResponse::WorkspaceFileWritten { .. }
15201 | NodeResponse::WorkspaceFileCreated { .. }
15202 | NodeResponse::WorkspaceDirectoryCreated { .. }
15203 | NodeResponse::GitHistoryRead { .. }
15204 | NodeResponse::GitDiffRead { .. }
15205 | NodeResponse::Controller { .. }
15206 | NodeResponse::SpawnAccepted { .. }
15207 | NodeResponse::SpawnSpecAccepted { .. }
15208 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15209 | NodeResponse::ManagedWorktreeCleanup { .. }
15210 | NodeResponse::NativeSessionsCataloged { .. }
15211 | NodeResponse::NativeSessionsPaged { .. }
15212 | NodeResponse::NativeSessionPreviewed { .. }
15213 | NodeResponse::SessionRecordPreviewed { .. }
15214 | NodeResponse::HistoryDiscovered { .. }
15215 | NodeResponse::HistoryLoaded { .. }
15216 | NodeResponse::ContextPackForSessionRecordExported { .. }
15217 | NodeResponse::ContextPackExported { .. }
15218 | NodeResponse::ContextPackForgotten { .. }
15219 | NodeResponse::DurableContextPackResolved { .. }
15220 | NodeResponse::ContextPackBytesRead { .. }
15221 | NodeResponse::SessionRecordUpdated { .. }
15222 | NodeResponse::ProviderSessionIndexed { .. }
15223 | NodeResponse::NativeSessionIndexed { .. }
15224 | NodeResponse::SessionRecordResumed { .. }
15225 | NodeResponse::SessionRecordForgotten { .. }
15226 | NodeResponse::WorkspaceUnregistered { .. }
15227 | NodeResponse::WorktreeRemoved { .. }
15228 | NodeResponse::Accepted
15229 | NodeResponse::ShuttingDown => {}
15230 }
15231}
15232
15233fn project_event_legacy_provider_ids(
15234 shared: &NodeShared,
15235 mut envelope: NodeEventEnvelope,
15236) -> Option<NodeEventEnvelope> {
15237 let include = match &mut envelope.event {
15238 NodeEvent::HarnessMcpReadCall { .. } => true,
15239 NodeEvent::Control { address, .. }
15240 | NodeEvent::AgentStream { address, .. }
15241 | NodeEvent::TerminalFrame { address, .. } => shared
15242 .validate_address(address)
15243 .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15244 NodeEvent::WorkspaceAdded { workspace } => {
15245 workspace
15246 .sessions
15247 .retain(|session| provider_id_is_legacy(&session.agent_id));
15248 true
15249 }
15250 NodeEvent::SessionRecordUpserted { record } => {
15251 provider_id_is_legacy(&record.provider)
15252 }
15253 NodeEvent::SessionRecordHistorySummarized { .. } => false,
15254 NodeEvent::SessionRecordRemoved { .. } => shared
15255 .history
15256 .lock()
15257 .unwrap_or_else(|poisoned| poisoned.into_inner())
15258 .removed_record_providers
15259 .get(&envelope.sequence)
15260 .is_some_and(provider_id_is_legacy),
15261 NodeEvent::ControllerChanged { .. }
15262 | NodeEvent::ManagedWorktreeUpserted { .. }
15263 | NodeEvent::ManagedWorktreeRemoved { .. }
15264 | NodeEvent::WorkspaceRemoved { .. }
15265 | NodeEvent::ResyncRequired { .. } => true,
15266 };
15267 include.then_some(envelope)
15268}
15269
15270fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15271 let Ok(response) = reply.result.as_mut() else {
15272 return;
15273 };
15274 match response {
15275 NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15276 snapshot.provider_runtime_statuses.clear();
15277 }
15278 NodeResponse::Armed { .. }
15279 | NodeResponse::Spawned { .. }
15280 | NodeResponse::Activated { .. }
15281 | NodeResponse::Aborted { .. }
15282 | NodeResponse::ReplyChunkAccepted { .. }
15283 | NodeResponse::CallRejected { .. }
15284 | NodeResponse::DeliveryStageBegun { .. }
15285 | NodeResponse::DeliveryBlobChunkAccepted { .. }
15286 | NodeResponse::DeliveryCommitted { .. }
15287 | NodeResponse::DeliveryStageAborted { .. }
15288 | NodeResponse::WorkspaceInspected { .. }
15289 | NodeResponse::HostDirectoriesBrowsed { .. }
15290 | NodeResponse::WorkspaceFileRead { .. }
15291 | NodeResponse::WorkspaceFileWritten { .. }
15292 | NodeResponse::WorkspaceFileCreated { .. }
15293 | NodeResponse::WorkspaceDirectoryCreated { .. }
15294 | NodeResponse::GitHistoryRead { .. }
15295 | NodeResponse::GitDiffRead { .. }
15296 | NodeResponse::Controller { .. }
15297 | NodeResponse::SpawnAccepted { .. }
15298 | NodeResponse::SpawnSpecAccepted { .. }
15299 | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15300 | NodeResponse::ManagedWorktreeCleanup { .. }
15301 | NodeResponse::NativeSessionsCataloged { .. }
15302 | NodeResponse::NativeSessionsPaged { .. }
15303 | NodeResponse::NativeSessionPreviewed { .. }
15304 | NodeResponse::SessionRecordPreviewed { .. }
15305 | NodeResponse::HistoryDiscovered { .. }
15306 | NodeResponse::HistoryLoaded { .. }
15307 | NodeResponse::ContextPackForSessionRecordExported { .. }
15308 | NodeResponse::ContextPackExported { .. }
15309 | NodeResponse::ContextPackForgotten { .. }
15310 | NodeResponse::DurableContextPackResolved { .. }
15311 | NodeResponse::ContextPackBytesRead { .. }
15312 | NodeResponse::SessionRecordUpdated { .. }
15313 | NodeResponse::ProviderSessionIndexed { .. }
15314 | NodeResponse::NativeSessionIndexed { .. }
15315 | NodeResponse::SessionRecordResumed { .. }
15316 | NodeResponse::SessionRecordForgotten { .. }
15317 | NodeResponse::WorkspaceRegistered { .. }
15318 | NodeResponse::StandaloneWorkspaceCreated { .. }
15319 | NodeResponse::WorkspaceUnregistered { .. }
15320 | NodeResponse::WorktreeCreated { .. }
15321 | NodeResponse::WorktreeRemoved { .. }
15322 | NodeResponse::Accepted
15323 | NodeResponse::ShuttingDown => {}
15324 }
15325}
15326
15327struct ControllerReleaseGuard {
15328 shared: Arc<NodeShared>,
15329 connection_id: u64,
15330}
15331
15332impl Drop for ControllerReleaseGuard {
15333 fn drop(&mut self) {
15334 self.shared.release_controller(self.connection_id);
15335 }
15336}
15337
15338struct AbortTaskOnDrop(AbortHandle);
15339
15340impl Drop for AbortTaskOnDrop {
15341 fn drop(&mut self) {
15342 self.0.abort();
15343 }
15344}
15345
15346fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15351 std::env::var(name)
15352 .ok()
15353 .and_then(|value| value.parse::<u64>().ok())
15354 .filter(|value| *value > 0)
15355 .unwrap_or(default)
15356 .min(max)
15357}
15358
15359fn workspace_inspection_time_budget_ms() -> u64 {
15360 env_config_bounded(
15361 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15362 WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15363 WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15364 )
15365}
15366
15367fn workspace_inspection_entry_cap() -> usize {
15368 env_config_bounded(
15369 "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15370 WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15371 WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15372 ) as usize
15373}
15374
15375struct WorkspaceWalkBudget {
15381 deadline: Instant,
15382 entry_cap: usize,
15383 entries_visited: u64,
15384 time_budget_exceeded: bool,
15385 entry_cap_exceeded: bool,
15386}
15387
15388impl WorkspaceWalkBudget {
15389 fn new(deadline: Instant, entry_cap: usize) -> Self {
15390 Self {
15391 deadline,
15392 entry_cap,
15393 entries_visited: 0,
15394 time_budget_exceeded: false,
15395 entry_cap_exceeded: false,
15396 }
15397 }
15398
15399 fn is_exhausted(&self) -> bool {
15400 self.time_budget_exceeded || self.entry_cap_exceeded
15401 }
15402
15403 fn record_visit(&mut self) -> bool {
15406 self.entries_visited += 1;
15407 if self.entries_visited as usize >= self.entry_cap {
15408 self.entry_cap_exceeded = true;
15409 }
15410 if Instant::now() >= self.deadline {
15411 self.time_budget_exceeded = true;
15412 }
15413 self.is_exhausted()
15414 }
15415}
15416
15417fn collect_workspace_entries(
15418 root: &Path,
15419 deadline: Instant,
15420 entry_cap: usize,
15421) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15422 let mut entries = Vec::new();
15423 let mut truncated = false;
15424 let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15425 walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15426 entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15430 (entries, truncated, budget)
15431}
15432
15433fn workspace_inspection_truncation(
15437 walk_budget: &WorkspaceWalkBudget,
15438 git_time_budget_exceeded: bool,
15439 elapsed: Duration,
15440) -> Option<WorkspaceInspectionTruncationV1> {
15441 if !walk_budget.time_budget_exceeded
15442 && !walk_budget.entry_cap_exceeded
15443 && !git_time_budget_exceeded
15444 {
15445 return None;
15446 }
15447 Some(WorkspaceInspectionTruncationV1 {
15448 walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15449 walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15450 git_time_budget_exceeded,
15451 entries_visited: walk_budget.entries_visited,
15452 elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15453 })
15454}
15455
15456fn walk_workspace_directory(
15457 directory: &Path,
15458 relative_directory: Option<&RepositoryPath>,
15459 depth: usize,
15460 entries: &mut Vec<WorkspaceEntry>,
15461 truncated: &mut bool,
15462 budget: &mut WorkspaceWalkBudget,
15463) {
15464 if budget.is_exhausted() {
15465 *truncated = true;
15466 return;
15467 }
15468 let read_dir = match std::fs::read_dir(directory) {
15469 Ok(read_dir) => read_dir,
15470 Err(_) => {
15471 *truncated = true;
15472 return;
15473 }
15474 };
15475 let mut children = Vec::new();
15476 for child in read_dir {
15477 match child {
15478 Ok(child) => children.push(child),
15479 Err(_) => *truncated = true,
15480 }
15481 }
15482 children.sort_by_key(|child| child.file_name());
15483 for child in children {
15484 if budget.record_visit() {
15485 *truncated = true;
15486 return;
15487 }
15488 if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15489 *truncated = true;
15490 return;
15491 }
15492 let file_type = match child.file_type() {
15493 Ok(file_type) => file_type,
15494 Err(_) => {
15495 *truncated = true;
15496 continue;
15497 }
15498 };
15499 if file_type.is_symlink() {
15500 continue;
15501 }
15502 let name = match child.file_name().into_string() {
15503 Ok(name) => name,
15504 Err(_) => {
15505 *truncated = true;
15506 continue;
15507 }
15508 };
15509 if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15510 continue;
15511 }
15512 let relative_path = match relative_directory {
15513 None => windows_repository_path(name),
15514 Some(parent) => parent
15515 .as_utf8()
15516 .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15517 };
15518 let Some(relative_path) = relative_path else {
15519 *truncated = true;
15520 continue;
15521 };
15522 let kind = if file_type.is_dir() {
15523 WorkspaceEntryKind::Directory
15524 } else if file_type.is_file() {
15525 WorkspaceEntryKind::File
15526 } else {
15527 continue;
15528 };
15529 entries.push(WorkspaceEntry {
15530 relative_path: relative_path.clone(),
15531 kind,
15532 });
15533 if file_type.is_dir() {
15534 if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15535 walk_workspace_directory(
15536 &child.path(),
15537 Some(&relative_path),
15538 depth + 1,
15539 entries,
15540 truncated,
15541 budget,
15542 );
15543 } else {
15544 *truncated = true;
15545 }
15546 }
15547 }
15548}
15549
15550fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15551 if value.contains('\\') {
15552 return None;
15553 }
15554 RepositoryPath::utf8(value).ok()
15555}
15556
15557fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15558 windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15559}
15560
15561fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15570 if name.eq_ignore_ascii_case(".git")
15571 || name.eq_ignore_ascii_case("target")
15572 || name.eq_ignore_ascii_case("node_modules")
15573 {
15574 return true;
15575 }
15576 path.join("CACHEDIR.TAG").is_file()
15577}
15578
15579fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15580 matches!(
15581 status,
15582 SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
15583 )
15584}
15585
15586#[derive(Clone, Debug, Eq, PartialEq)]
15587enum ContextPackRepositoryHead {
15588 NotRepository,
15589 Unborn,
15590 Commit(String),
15591}
15592
15593#[derive(Clone, Debug, Eq, PartialEq)]
15594struct ContextPackRepositoryObservation {
15595 head: ContextPackRepositoryHead,
15596 git: GitSnapshot,
15597 files: Vec<ContextPackRepositoryFileSource>,
15598}
15599
15600fn stable_context_pack_repository(
15601 first: ContextPackRepositoryObservation,
15602 second: ContextPackRepositoryObservation,
15603) -> Result<ContextPackRepository, NodeFailure> {
15604 if first != second {
15605 return Err(failure(
15606 NodeFailureCode::ContextPackMaterializationFailed,
15607 "context repository changed during bounded capture",
15608 ));
15609 }
15610 Ok(ContextPackRepository::from_sources(
15611 &second.git,
15612 second.files,
15613 ))
15614}
15615
15616fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
15623 if Instant::now() < deadline {
15624 return false;
15625 }
15626 append_git_diagnostic(
15627 snapshot,
15628 &format!("{stage} skipped: workspace inspection time budget exhausted"),
15629 );
15630 true
15631}
15632
15633async fn run_git_bounded_with_deadline(
15642 root: &str,
15643 arguments: &[&str],
15644 output_limit: usize,
15645 deadline: Instant,
15646) -> io::Result<GitCommandOutput> {
15647 let remaining = deadline.checked_duration_since(Instant::now());
15648 let timeout_ms = remaining
15649 .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
15650 .unwrap_or(0)
15651 .clamp(1, GIT_COMMAND_TIMEOUT_MS);
15652 run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
15653}
15654
15655async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
15662 let mut snapshot = GitSnapshot {
15663 is_repository: false,
15664 branch: None,
15665 status: Vec::new(),
15666 recent_commits: Vec::new(),
15667 worktrees: Vec::new(),
15668 managed_worktree: None,
15669 truncated: false,
15670 diagnostic: None,
15671 };
15672 if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
15673 return (snapshot, true);
15674 }
15675 let repository = match run_git_bounded_with_deadline(
15676 root,
15677 &["rev-parse", "--is-inside-work-tree"],
15678 4 * 1_024,
15679 deadline,
15680 )
15681 .await
15682 {
15683 Ok(output) => output,
15684 Err(error) => {
15685 append_git_diagnostic(
15686 &mut snapshot,
15687 &format!("git inspection unavailable: {error}"),
15688 );
15689 return (snapshot, false);
15690 }
15691 };
15692 snapshot.truncated |= repository.truncated;
15693 if repository.timed_out {
15694 append_git_diagnostic(&mut snapshot, "git repository probe timed out");
15695 return (snapshot, false);
15696 }
15697 if !repository.success
15698 || String::from_utf8_lossy(&repository.stdout).trim() != "true"
15699 {
15700 let stderr = String::from_utf8_lossy(&repository.stderr);
15701 if !stderr.to_ascii_lowercase().contains("not a git repository") {
15702 append_git_diagnostic(&mut snapshot, stderr.trim());
15703 }
15704 return (snapshot, false);
15705 }
15706 snapshot.is_repository = true;
15707
15708 if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
15709 return (snapshot, true);
15710 }
15711 match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
15712 Ok(output) => {
15713 snapshot.truncated |= output.truncated;
15714 if output.timed_out {
15715 append_git_diagnostic(&mut snapshot, "git branch query timed out");
15716 } else if output.success {
15717 let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15718 if !branch.is_empty() {
15719 snapshot.branch = Some(truncate_git_field(
15720 &branch,
15721 MAX_REPOSITORY_PATH_BYTES,
15722 &mut snapshot.truncated,
15723 ));
15724 }
15725 }
15726 }
15727 Err(error) => append_git_diagnostic(
15728 &mut snapshot,
15729 &format!("git branch query failed: {error}"),
15730 ),
15731 }
15732 if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
15733 if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
15734 snapshot.truncated |= output.truncated;
15735 if output.success && !output.timed_out {
15736 let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
15737 if !head.is_empty() {
15738 snapshot.branch = Some(format!("detached:{head}"));
15739 }
15740 }
15741 }
15742 }
15743
15744 if git_budget_exhausted(&mut snapshot, deadline, "git status") {
15745 return (snapshot, true);
15746 }
15747 match run_git_bounded_with_deadline(
15748 root,
15749 &[
15750 "status",
15751 "--porcelain=v1",
15752 "-z",
15753 "--renames",
15754 "--untracked-files=normal",
15755 "--",
15756 ".",
15757 ],
15758 GIT_OUTPUT_MAX_BYTES,
15759 deadline,
15760 )
15761 .await
15762 {
15763 Ok(output) => {
15764 snapshot.truncated |= output.truncated;
15765 if output.timed_out {
15766 append_git_diagnostic(&mut snapshot, "git status timed out");
15767 } else if output.success {
15768 parse_git_status(&output.stdout, &mut snapshot);
15769 } else {
15770 append_git_diagnostic(
15771 &mut snapshot,
15772 String::from_utf8_lossy(&output.stderr).trim(),
15773 );
15774 }
15775 }
15776 Err(error) => append_git_diagnostic(
15777 &mut snapshot,
15778 &format!("git status failed: {error}"),
15779 ),
15780 }
15781
15782 if git_budget_exhausted(&mut snapshot, deadline, "git log") {
15783 return (snapshot, true);
15784 }
15785 let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
15786 match run_git_bounded_with_deadline(
15787 root,
15788 &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
15789 16 * 1_024,
15790 deadline,
15791 )
15792 .await
15793 {
15794 Ok(output) => {
15795 snapshot.truncated |= output.truncated;
15796 if output.timed_out {
15797 append_git_diagnostic(&mut snapshot, "git log timed out");
15798 } else if output.success {
15799 parse_git_commits(&output.stdout, &mut snapshot);
15800 } else {
15801 let stderr = String::from_utf8_lossy(&output.stderr);
15802 if !stderr.to_ascii_lowercase().contains("does not have any commits") {
15803 append_git_diagnostic(&mut snapshot, stderr.trim());
15804 }
15805 }
15806 }
15807 Err(error) => append_git_diagnostic(
15808 &mut snapshot,
15809 &format!("git log failed: {error}"),
15810 ),
15811 }
15812
15813 if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
15814 return (snapshot, true);
15815 }
15816 match list_git_worktrees_with_deadline(root, deadline).await {
15817 Ok(worktrees) => {
15818 snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
15819 }
15820 Err(error) => append_git_diagnostic(
15821 &mut snapshot,
15822 &format!("git worktree list failed: {}", error.message),
15823 ),
15824 }
15825 (snapshot, false)
15826}
15827
15828fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
15829 let mut remaining = output;
15830 while !remaining.is_empty() {
15831 if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
15832 snapshot.truncated = true;
15833 break;
15834 }
15835 let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
15836 snapshot.truncated = true;
15837 break;
15838 };
15839 let record = &remaining[..record_end];
15840 remaining = &remaining[record_end + 1..];
15841 let has_previous_path = record
15842 .get(..2)
15843 .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
15844 let previous_path_bytes = if has_previous_path {
15845 let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
15846 snapshot.truncated = true;
15847 break;
15848 };
15849 let previous = &remaining[..previous_end];
15850 remaining = &remaining[previous_end + 1..];
15851 Some(previous)
15852 } else {
15853 None
15854 };
15855 if record.len() < 4
15856 || record[2] != b' '
15857 || !is_git_status_code(record[0])
15858 || !is_git_status_code(record[1])
15859 {
15860 snapshot.truncated = true;
15861 continue;
15862 }
15863 let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
15864 snapshot.truncated = true;
15865 continue;
15866 };
15867 let previous_path = match previous_path_bytes {
15868 Some(previous) => {
15869 let Some(previous) = windows_repository_path_from_bytes(previous) else {
15870 snapshot.truncated = true;
15871 continue;
15872 };
15873 Some(previous)
15874 }
15875 None => None,
15876 };
15877 snapshot.status.push(GitStatusEntry {
15878 index_status: (record[0] as char).to_string(),
15879 worktree_status: (record[1] as char).to_string(),
15880 path,
15881 previous_path,
15882 });
15883 }
15884}
15885
15886fn is_git_status_code(byte: u8) -> bool {
15887 matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
15888}
15889
15890fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
15891 for line in String::from_utf8_lossy(output).lines() {
15892 let Some((id, summary)) = line.split_once('\u{1f}') else {
15893 continue;
15894 };
15895 if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
15896 snapshot.truncated = true;
15897 break;
15898 }
15899 let id = truncate_git_field(id, 64, &mut snapshot.truncated);
15900 let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
15901 snapshot.recent_commits.push(GitCommitSummary { id, summary });
15902 }
15903}
15904
15905fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
15906 if value.len() <= max_bytes {
15907 return value.to_owned();
15908 }
15909 *truncated = true;
15910 let mut boundary = max_bytes;
15911 while boundary > 0 && !value.is_char_boundary(boundary) {
15912 boundary -= 1;
15913 }
15914 value[..boundary].to_owned()
15915}
15916
15917fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
15918 if message.is_empty() {
15919 return;
15920 }
15921 let normalized = message
15922 .chars()
15923 .map(|character| if character.is_control() { ' ' } else { character })
15924 .collect::<String>();
15925 let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
15926 if !diagnostic.is_empty() {
15927 diagnostic.push_str("; ");
15928 }
15929 diagnostic.push_str(normalized.trim());
15930 snapshot.diagnostic = Some(truncate_git_field(
15931 &diagnostic,
15932 GIT_DIAGNOSTIC_MAX_BYTES,
15933 &mut snapshot.truncated,
15934 ));
15935}
15936
15937async fn run_git_bounded(
15938 root: &str,
15939 arguments: &[&str],
15940 output_limit: usize,
15941) -> io::Result<GitCommandOutput> {
15942 run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
15943}
15944
15945async fn read_git_history_bounded(
15946 root: &str,
15947 path: Option<&RepositoryPath>,
15948 before: Option<&GitObjectId>,
15949 limit: u16,
15950) -> Result<GitHistoryPage, NodeFailure> {
15951 let path = path
15952 .map(|path| {
15953 path.as_utf8().ok_or_else(|| {
15954 failure(
15955 NodeFailureCode::InvalidRepositoryPath,
15956 "git history path must use UTF-8",
15957 )
15958 })
15959 })
15960 .transpose()?
15961 .unwrap_or(".");
15962 let count = (usize::from(limit) + 1).to_string();
15963 let start = before
15964 .map(|revision| format!("{}^", revision.as_str()))
15965 .unwrap_or_else(|| "HEAD".to_owned());
15966 let arguments = [
15967 "--no-pager",
15968 "log",
15969 "--no-decorate",
15970 "--date=iso-strict",
15971 "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
15972 "-n",
15973 count.as_str(),
15974 start.as_str(),
15975 "--",
15976 path,
15977 ];
15978 let output = run_git_bounded(root, &arguments, 256 * 1_024)
15979 .await
15980 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
15981 if output.timed_out {
15982 return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
15983 }
15984 if !output.success {
15985 return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
15986 }
15987 let mut commits = parse_git_history(&output.stdout);
15988 let has_more = commits.len() > usize::from(limit) || output.truncated;
15989 commits.truncate(usize::from(limit));
15990 let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
15991 Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
15992}
15993
15994fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
15995 String::from_utf8_lossy(output)
15996 .split('\u{1e}')
15997 .filter_map(|record| {
15998 let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
15999 if fields.len() != 9 {
16000 return None;
16001 }
16002 let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16003 let parents = if fields[1].is_empty() {
16004 Vec::new()
16005 } else {
16006 fields[1]
16007 .split(' ')
16008 .map(|value| GitObjectId::new(value.to_owned()))
16009 .collect::<Result<Vec<_>, _>>()
16010 .ok()?
16011 };
16012 Some(GitCommitDetails {
16013 id,
16014 parents,
16015 subject: bounded_git_text(fields[2], 512),
16016 author_name: bounded_git_text(fields[3], 256),
16017 author_email: bounded_git_text(fields[4], 320),
16018 authored_at: bounded_git_text(fields[5], 64),
16019 committer_name: bounded_git_text(fields[6], 256),
16020 committer_email: bounded_git_text(fields[7], 320),
16021 committed_at: bounded_git_text(fields[8], 64),
16022 signature_status: GitSignatureStatus::NoSignature,
16023 signer: None,
16024 })
16025 })
16026 .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16027 .collect()
16028}
16029
16030fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16031 let normalized = value
16032 .chars()
16033 .map(|character| if character.is_control() { ' ' } else { character })
16034 .collect::<String>();
16035 if normalized.len() <= max_bytes {
16036 return normalized;
16037 }
16038 let mut boundary = max_bytes;
16039 while boundary > 0 && !normalized.is_char_boundary(boundary) {
16040 boundary -= 1;
16041 }
16042 normalized[..boundary].to_owned()
16043}
16044
16045async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16046 let path = request.path.as_ref().map(|path| {
16047 path.as_utf8()
16048 .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16049 }).transpose()?;
16050 let mut arguments = match &request.mode {
16051 GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16052 GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16053 GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16054 };
16055 if let GitDiffMode::Commit { revision } = &request.mode {
16056 arguments.push(revision.as_str());
16057 }
16058 arguments.push("--");
16059 if let Some(path) = path {
16060 arguments.push(path);
16061 }
16062 let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16063 .await
16064 .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16065 if output.timed_out {
16066 return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16067 }
16068 if !output.success {
16069 return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16070 }
16071 Ok(GitDiff {
16072 mode: request.mode,
16073 path: request.path,
16074 text: String::from_utf8_lossy(&output.stdout).into_owned(),
16075 truncated: output.truncated,
16076 })
16077}
16078
16079async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16080 let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16081 ResponseEnvelope { request_id: envelope.request_id, result }
16082}
16083
16084async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16085 if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16086 return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16087 }
16088 let read_only = matches!(
16089 &request,
16090 NodeRequest::Snapshot
16091 | NodeRequest::Resync { .. }
16092 | NodeRequest::BrowseHostDirectories { .. }
16093 | NodeRequest::InspectWorkspace { .. }
16094 | NodeRequest::ReadWorkspaceFile { .. }
16095 | NodeRequest::ReadGitHistory { .. }
16096 | NodeRequest::ReadGitDiff { .. }
16097 | NodeRequest::CatalogNativeSessions { .. }
16098 | NodeRequest::PageNativeSessions { .. }
16099 | NodeRequest::PreviewNativeSession { .. }
16100 | NodeRequest::PreviewSessionRecord { .. }
16101 );
16102 let _mutation_guard = if read_only {
16103 None
16104 } else {
16105 let guard = shared.mutation_gate.lock().await;
16106 if shared.shutdown.load(Ordering::Acquire) {
16107 return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16108 }
16109 Some(guard)
16110 };
16111 match request {
16112 NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16113 event_sequence: shared.current_sequence(),
16114 controller: shared.controller_state(),
16115 snapshot: shared.snapshot(),
16116 }),
16117 NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16118 NodeRequest::ArmHarnessMcpReservation {
16119 reservation_id,
16120 activation_digest,
16121 spawn_spec,
16122 launch,
16123 expires_at_unix_ms,
16124 } => {
16125 shared.require_controller(connection_id, role)?;
16126 let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16127 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16128 })?;
16129 let expires_at_unix_ms = registry
16130 .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16131 .await
16132 .map_err(harness_mcp_failure)?;
16133 Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16134 }
16135 NodeRequest::SpawnSpecWithHarnessMcp {
16136 reservation_id,
16137 activation_digest,
16138 spec,
16139 deadline_unix_ms,
16140 } => {
16141 shared.require_controller(connection_id, role)?;
16142 let receipt = shared
16143 .spawn_from_spec_with_harness_mcp(
16144 reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16145 )
16146 .await?;
16147 Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16148 }
16149 NodeRequest::ActivateHarnessMcpReservation {
16150 reservation_id,
16151 activation_digest,
16152 record_id,
16153 session,
16154 } => {
16155 shared.require_controller(connection_id, role)?;
16156 let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16157 current.instance_id == session.session.instance_id
16158 && current.generation == session.session.generation
16159 }).and_then(|current| current.process_id).ok_or_else(|| {
16160 failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16161 })?;
16162 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16163 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16164 })?.activate(
16165 &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16166 ).map_err(harness_mcp_failure)?;
16167 Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16168 }
16169 NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16170 shared.require_controller(connection_id, role)?;
16171 let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16172 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16173 })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16174 if let Some(instance_id) = instance_id {
16175 SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16176 }
16177 Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16178 }
16179 NodeRequest::PutHarnessMcpReplyChunk {
16180 reservation_id, activation_digest, record_id, session, call_id,
16181 offset, final_chunk, chunk_hex,
16182 } => {
16183 shared.require_controller(connection_id, role)?;
16184 let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16185 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16186 })?.put_reply_chunk(
16187 &reservation_id, &activation_digest, &record_id, &session, &call_id,
16188 offset, final_chunk, &chunk_hex,
16189 ).map_err(harness_mcp_failure)?;
16190 Ok(NodeResponse::ReplyChunkAccepted {
16191 reservation_id, activation_digest, record_id, session, call_id,
16192 next_offset, completed,
16193 })
16194 }
16195 NodeRequest::RejectHarnessMcpCall {
16196 reservation_id, activation_digest, record_id, session, call_id, reason,
16197 } => {
16198 shared.require_controller(connection_id, role)?;
16199 shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16200 failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16201 })?.reject_call(
16202 &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16203 ).map_err(harness_mcp_failure)?;
16204 Ok(NodeResponse::CallRejected {
16205 reservation_id, activation_digest, record_id, session, call_id,
16206 })
16207 }
16208 NodeRequest::BeginDeliveryStage { manifest } => {
16209 shared.require_controller(connection_id, role)?;
16210 shared.begin_delivery_stage(manifest)
16211 }
16212 NodeRequest::PutDeliveryBlobChunk {
16213 stage_id,
16214 blob_digest,
16215 offset,
16216 chunk_hex,
16217 } => {
16218 shared.require_controller(connection_id, role)?;
16219 shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16220 }
16221 NodeRequest::CommitDeliveryStage { stage_id } => {
16222 shared.require_controller(connection_id, role)?;
16223 let receipt = shared.commit_delivery_stage(stage_id)?;
16224 Ok(NodeResponse::DeliveryCommitted { receipt })
16225 }
16226 NodeRequest::AbortDeliveryStage { stage_id } => {
16227 shared.require_controller(connection_id, role)?;
16228 shared.abort_delivery_stage(&stage_id)?;
16229 Ok(NodeResponse::DeliveryStageAborted { stage_id })
16230 }
16231 NodeRequest::BrowseHostDirectories { directory, after } => {
16232 let listing = shared.browse_host_directories(directory, after).await?;
16233 Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16234 }
16235 NodeRequest::InspectWorkspace { workspace_id } => {
16236 let inspection = shared.inspect_workspace(workspace_id).await?;
16237 Ok(NodeResponse::WorkspaceInspected { inspection })
16238 }
16239 NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16240 let file = shared.read_workspace_file(workspace_id, path).await?;
16241 Ok(NodeResponse::WorkspaceFileRead { file })
16242 }
16243 NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16244 shared.require_controller(connection_id, role)?;
16245 let file = shared
16246 .write_workspace_file(workspace_id, path, expected_revision, text)
16247 .await?;
16248 Ok(NodeResponse::WorkspaceFileWritten { file })
16249 }
16250 NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16251 shared.require_controller(connection_id, role)?;
16252 let file = shared.create_workspace_file(workspace_id, path).await?;
16253 Ok(NodeResponse::WorkspaceFileCreated { file })
16254 }
16255 NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16256 shared.require_controller(connection_id, role)?;
16257 let entry = shared
16258 .create_workspace_directory(workspace_id.clone(), path)
16259 .await?;
16260 Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16261 }
16262 NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16263 if role != ClientRole::Operator {
16264 return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16265 }
16266 if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16267 return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16268 }
16269 let page = shared
16270 .read_git_history(workspace_id.clone(), path, before, limit)
16271 .await?;
16272 Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16273 }
16274 NodeRequest::ReadGitDiff { workspace_id, request } => {
16275 if role != ClientRole::Operator {
16276 return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16277 }
16278 let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16279 Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16280 }
16281 NodeRequest::CatalogNativeSessions { route, limit } => {
16282 if role != ClientRole::Operator {
16283 return Err(failure(
16284 NodeFailureCode::ObserverReadOnly,
16285 "native session catalog requires an operator connection",
16286 ));
16287 }
16288 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16289 return Err(failure(
16290 NodeFailureCode::InvalidRequest,
16291 "native session catalog limit is invalid",
16292 ));
16293 }
16294 let (entries, summary) = shared
16295 .catalog_native_sessions(route.clone(), limit)
16296 .await?;
16297 Ok(NodeResponse::NativeSessionsCataloged {
16298 route,
16299 entries,
16300 summary: Some(summary),
16301 })
16302 }
16303 NodeRequest::PageNativeSessions {
16304 route,
16305 window,
16306 catalog_revision,
16307 recent_cutoff_unix_ms,
16308 after_selection_id,
16309 limit,
16310 } => {
16311 if role != ClientRole::Operator {
16312 return Err(failure(
16313 NodeFailureCode::ObserverReadOnly,
16314 "native session catalog paging requires an operator connection",
16315 ));
16316 }
16317 if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16318 || after_selection_id
16319 .as_deref()
16320 .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16321 {
16322 return Err(failure(
16323 NodeFailureCode::InvalidRequest,
16324 "native session catalog page request is invalid",
16325 ));
16326 }
16327 let page = shared
16328 .page_native_sessions(
16329 route.clone(),
16330 window,
16331 catalog_revision,
16332 recent_cutoff_unix_ms,
16333 after_selection_id,
16334 limit,
16335 )
16336 .await?;
16337 Ok(NodeResponse::NativeSessionsPaged {
16338 route,
16339 page,
16340 })
16341 }
16342 NodeRequest::PreviewNativeSession {
16343 selection,
16344 message_limit,
16345 } => {
16346 if role != ClientRole::Operator {
16347 return Err(failure(
16348 NodeFailureCode::ObserverReadOnly,
16349 "native session preview requires an operator connection",
16350 ));
16351 }
16352 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16353 .contains(&message_limit)
16354 || selection.validate().is_err()
16355 {
16356 return Err(failure(
16357 NodeFailureCode::InvalidRequest,
16358 "native session preview request is invalid",
16359 ));
16360 }
16361 let preview = shared
16362 .preview_native_session(selection.clone(), message_limit)
16363 .await?;
16364 Ok(NodeResponse::NativeSessionPreviewed {
16365 selection,
16366 preview,
16367 })
16368 }
16369 NodeRequest::IndexNativeSession {
16370 selection,
16371 display_name,
16372 } => {
16373 shared.require_controller(connection_id, role)?;
16374 let record = shared
16375 .index_native_session(selection.clone(), display_name)
16376 .await?;
16377 Ok(NodeResponse::NativeSessionIndexed { selection, record })
16378 }
16379 NodeRequest::PreviewSessionRecord {
16380 record_id,
16381 message_limit,
16382 } => {
16383 if role != ClientRole::Operator {
16384 return Err(failure(
16385 NodeFailureCode::ObserverReadOnly,
16386 "native session preview requires an operator connection",
16387 ));
16388 }
16389 if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16390 .contains(&message_limit)
16391 {
16392 return Err(failure(
16393 NodeFailureCode::InvalidRequest,
16394 "native session preview request is invalid",
16395 ));
16396 }
16397 let record = shared.record(&record_id)?;
16398 let working_directory = shared.workspace_root(&record.workspace_id)?;
16399 if !platform::roots_equal(
16400 &working_directory,
16401 windows_path_text(&record.canonical_root),
16402 ) {
16403 return Err(failure(
16404 NodeFailureCode::SessionRecordConflict,
16405 "managed session workspace root changed; refusing to preview another directory",
16406 ));
16407 }
16408 let identity = record.provider_session.clone().ok_or_else(|| {
16409 failure(
16410 NodeFailureCode::BackendOperationFailed,
16411 "native session preview is unavailable for this session record",
16412 )
16413 })?;
16414 if identity.key != ProviderSessionKey::SessionId {
16415 return Err(failure(
16416 NodeFailureCode::BackendOperationFailed,
16417 "native session preview is unavailable for this session identity",
16418 ));
16419 }
16420 let preview = shared
16421 .preview_native_session_by_session_id(
16422 record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16423 )
16424 .await?;
16425 if preview.session_id != identity.id {
16426 return Err(failure(
16427 NodeFailureCode::SessionRecordConflict,
16428 "native session preview identity changed while loading",
16429 ));
16430 }
16431 shared.revalidate_session_record_preview(&record, &identity)?;
16432 let preview: gate4agent_types::SessionRecordPreview = preview.into();
16433 shared.publish(NodeEvent::SessionRecordHistorySummarized {
16434 record_id: record_id.clone(),
16435 summary: SessionHistorySummaryV1::from(&preview),
16436 });
16437 Ok(NodeResponse::SessionRecordPreviewed {
16438 record_id,
16439 preview,
16440 })
16441 }
16442 NodeRequest::AcquireController { lease_ms } => {
16443 let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16444 Ok(NodeResponse::Controller { controller: Some(controller) })
16445 }
16446 NodeRequest::ReleaseController => {
16447 shared.release_controller(connection_id);
16448 Ok(NodeResponse::Controller { controller: shared.controller_state() })
16449 }
16450 NodeRequest::RegisterWorkspace { workspace_id, root } => {
16451 shared.require_controller(connection_id, role)?;
16452 let root = require_windows_path(root)?;
16453 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16454 let workspace = shared.register_workspace(workspace_id, root).await?;
16455 Ok(NodeResponse::WorkspaceRegistered { workspace })
16456 }
16457 NodeRequest::CreateStandaloneWorkspace {
16458 workspace_id,
16459 root,
16460 initial_branch,
16461 } => {
16462 shared.require_controller(connection_id, role)?;
16463 let root = require_windows_path(root)?;
16464 shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16465 let workspace = shared
16466 .create_standalone_workspace(workspace_id, root, initial_branch)
16467 .await?;
16468 Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16469 }
16470 NodeRequest::UnregisterWorkspace { workspace_id } => {
16471 shared.require_controller(connection_id, role)?;
16472 shared.reject_managed_reservation(Some(&workspace_id), None)?;
16473 shared.unregister_workspace(&workspace_id)?;
16474 Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16475 }
16476 NodeRequest::CreateWorktree {
16477 source_workspace_id,
16478 workspace_id,
16479 target_root,
16480 branch,
16481 base,
16482 } => {
16483 shared.require_controller(connection_id, role)?;
16484 let target_root = require_windows_path(target_root)?;
16485 shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16486 let (worktree, workspace) = shared
16487 .create_worktree(
16488 source_workspace_id,
16489 workspace_id,
16490 target_root,
16491 branch,
16492 base,
16493 )
16494 .await?;
16495 Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16496 }
16497 NodeRequest::RemoveWorktree {
16498 source_workspace_id,
16499 target_root,
16500 } => {
16501 shared.require_controller(connection_id, role)?;
16502 let native_target_root = require_windows_path(target_root.clone())?;
16503 shared.reject_managed_reservation(None, Some(&native_target_root))?;
16504 let workspace_id = shared
16505 .remove_worktree(source_workspace_id, native_target_root)
16506 .await?;
16507 Ok(NodeResponse::WorktreeRemoved {
16508 target_root,
16509 workspace_id,
16510 })
16511 }
16512 NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16513 shared.require_controller(connection_id, role)?;
16514 if !terminal_size.is_valid() {
16515 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16516 }
16517 if let Some(prompt) = initial_prompt.as_deref() {
16518 validate_node_text("spawn initial prompt", prompt)?;
16519 }
16520 match shared
16521 .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16522 .await
16523 {
16524 Ok(session) => {
16525 shared.mark_spawn_provisional(session.clone());
16526 Ok(NodeResponse::SpawnAccepted { session })
16527 }
16528 Err(err) => {
16529 tracing::warn!(
16530 %provider,
16531 %workspace_id,
16532 ?mode,
16533 code = ?err.code,
16534 message = %err.message,
16535 "session spawn refused"
16536 );
16537 Err(err)
16538 }
16539 }
16540 }
16541 NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16542 shared.require_controller(connection_id, role)?;
16543 if !terminal_size.is_valid() {
16544 return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16545 }
16546 if let Some(prompt) = initial_prompt.as_deref() {
16547 validate_node_text("spawn initial prompt", prompt)?;
16548 }
16549 let plan = backend_paths::plan_launch_options(
16550 provider.as_str(),
16551 mode,
16552 options.model.is_some(),
16553 options.effort.is_some(),
16554 )
16555 .map_err(|r| {
16556 tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16557 failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16558 })?;
16559 let selection = spawn_selection(&options)?;
16560 let pty_selection = match plan {
16561 backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16562 _ => None,
16563 };
16564 let spawned = SPAWN_SESSION_OPTIONS
16565 .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16566 .await;
16567 match spawned {
16568 Ok(session) => {
16569 if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16570 shared
16571 .pending_acp_options
16572 .lock()
16573 .unwrap_or_else(|p| p.into_inner())
16574 .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16575 }
16576 shared.mark_spawn_provisional(session.clone());
16577 tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
16578 Ok(NodeResponse::SpawnAccepted { session })
16579 }
16580 Err(err) => {
16581 tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
16582 Err(err)
16583 }
16584 }
16585 }
16586 NodeRequest::SpawnSpec { spec } => {
16587 shared.require_controller(connection_id, role)?;
16588 let receipt = shared.spawn_from_spec(spec).await?;
16589 Ok(NodeResponse::SpawnSpecAccepted { receipt })
16590 }
16591 NodeRequest::SpawnManagedWorktree { request } => {
16592 shared.require_controller(connection_id, role)?;
16593 let receipt = shared.spawn_managed_worktree(request).await?;
16594 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16595 }
16596 NodeRequest::SpawnManagedWorktreeV2 { request } => {
16597 shared.require_controller(connection_id, role)?;
16598 let receipt = shared.spawn_managed_worktree_v2(request).await?;
16599 Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
16600 }
16601 NodeRequest::CleanupManagedWorktree { lease_id } => {
16602 shared.require_controller(connection_id, role)?;
16603 let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
16604 Ok(NodeResponse::ManagedWorktreeCleanup { lease })
16605 }
16606 NodeRequest::DiscoverHistory { session, limit } => {
16607 controlled_session(shared, connection_id, role, &session)?;
16608 let candidates = shared.discover_history(&session, limit).await?;
16609 Ok(NodeResponse::HistoryDiscovered {
16610 session,
16611 candidates,
16612 })
16613 }
16614 NodeRequest::LoadHistory {
16615 session,
16616 candidate_id,
16617 } => {
16618 controlled_session(shared, connection_id, role, &session)?;
16619 let loaded = shared
16620 .load_history(&session, candidate_id)
16621 .await?;
16622 Ok(NodeResponse::HistoryLoaded {
16623 session,
16624 session_id: loaded.session_id,
16625 message_count: loaded.message_count,
16626 completed_turn_count: loaded.completed_turn_count,
16627 })
16628 }
16629 NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
16630 shared.require_controller(connection_id, role)?;
16631 let context = shared
16632 .export_context_pack_for_session_record(&record_id, &session, false)
16633 .await?;
16634 Ok(NodeResponse::ContextPackForSessionRecordExported {
16635 record_id,
16636 session,
16637 context,
16638 })
16639 }
16640 NodeRequest::ExportContextPack { session } => {
16641 controlled_session(shared, connection_id, role, &session)?;
16642 let context = shared.export_context_pack(&session).await?;
16643 Ok(NodeResponse::ContextPackExported { context })
16644 }
16645 NodeRequest::ForgetContextPack { context_id } => {
16646 shared.require_controller(connection_id, role)?;
16647 shared.forget_context_pack(&context_id)?;
16648 Ok(NodeResponse::ContextPackForgotten { context_id })
16649 }
16650 NodeRequest::ResolveDurableContextPack { context_id } => {
16651 let context = shared
16655 .context_catalog
16656 .read()
16657 .unwrap_or_else(|poisoned| poisoned.into_inner())
16658 .get(&context_id)
16659 .map(|pack| pack.receipt().clone())
16660 .ok_or_else(|| {
16661 failure(
16662 NodeFailureCode::UnknownContextPack,
16663 "context pack is not present",
16664 )
16665 })?;
16666 Ok(NodeResponse::DurableContextPackResolved { context })
16667 }
16668 NodeRequest::ReadContextPack { digest } => {
16669 let (id, byte_len, bytes) = shared
16674 .context_catalog
16675 .read()
16676 .unwrap_or_else(|poisoned| poisoned.into_inner())
16677 .get_by_digest(&digest)
16678 .map(|pack| {
16679 (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
16680 })
16681 .ok_or_else(|| {
16682 failure(
16683 NodeFailureCode::UnknownContextPack,
16684 &format!("context pack {digest} not on this node"),
16685 )
16686 })?;
16687 tracing::debug!(
16688 digest = digest.as_str(),
16689 byte_len,
16690 "read context pack bytes",
16691 );
16692 Ok(NodeResponse::ContextPackBytesRead {
16693 pack: ContextPackBytesRead {
16694 digest,
16695 id,
16696 byte_len,
16697 bytes,
16698 },
16699 })
16700 }
16701 NodeRequest::Resume { session, terminal_size, initial_prompt } => {
16702 let provider = controlled_session(shared, connection_id, role, &session)?;
16703 if let Some(prompt) = initial_prompt.as_deref() {
16704 validate_node_text("resume initial prompt", prompt)?;
16705 }
16706 let runtime_requirement = if initial_prompt.is_some() {
16707 ProviderRuntimeRequirement::ResumeWithPrompt
16708 } else {
16709 ProviderRuntimeRequirement::Resume
16710 };
16711 shared.require_session_runtime_policy(&session, runtime_requirement)?;
16712 let runtime_policy = shared
16713 .admit_provider_runtime(&provider, runtime_requirement)
16714 .await?;
16715 let working_directory = shared.bound_workspace_root(&session)?;
16716 let request = ResumeLaunchRequest {
16717 working_directory,
16718 terminal_size,
16719 initial_prompt,
16720 };
16721 request
16722 .validate()
16723 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16724 let command = shared.prepare_command(ControlCommand::Resume {
16725 instance_id: session.session.instance_id,
16726 target: ResumeTarget::CurrentProvider,
16727 runtime_policy,
16728 request,
16729 });
16730 shared.arm_resume(&session, command.id, runtime_policy)?;
16731 let dispatch = shared.dispatch_envelope(command);
16732 if let Err(error) = dispatch {
16733 shared.clear_armed_resume(&session);
16734 return Err(error);
16735 }
16736 Ok(NodeResponse::Accepted)
16737 }
16738 NodeRequest::RenameSessionRecord {
16739 record_id,
16740 display_name,
16741 } => {
16742 shared.require_controller(connection_id, role)?;
16743 let record = shared.rename_session_record(&record_id, display_name)?;
16744 Ok(NodeResponse::SessionRecordUpdated { record })
16745 }
16746 NodeRequest::SetSessionTask {
16747 record_id,
16748 expected_revision,
16749 target,
16750 } => {
16751 shared.require_controller(connection_id, role)?;
16752 let record = shared.set_session_task(&record_id, expected_revision, target)?;
16753 Ok(NodeResponse::SessionRecordUpdated { record })
16754 }
16755 NodeRequest::IndexProviderSession {
16756 workspace_id,
16757 provider,
16758 identity,
16759 display_name,
16760 } => {
16761 shared.require_controller(connection_id, role)?;
16762 let record = shared.index_provider_session(
16763 workspace_id,
16764 provider,
16765 identity,
16766 display_name,
16767 )?;
16768 Ok(NodeResponse::ProviderSessionIndexed { record })
16769 }
16770 NodeRequest::ResumeSessionRecord {
16771 record_id,
16772 terminal_size,
16773 initial_prompt,
16774 } => {
16775 shared.require_controller(connection_id, role)?;
16776 if let Some(prompt) = initial_prompt.as_deref() {
16777 validate_node_text("managed resume initial prompt", prompt)?;
16778 }
16779 let (record, session) = shared
16780 .resume_session_record(
16781 &record_id,
16782 terminal_size,
16783 initial_prompt,
16784 )
16785 .await?;
16786 Ok(NodeResponse::SessionRecordResumed { record, session })
16787 }
16788 NodeRequest::ForgetSessionRecord { record_id } => {
16789 shared.require_controller(connection_id, role)?;
16790 shared.forget_session_record(&record_id).await?;
16791 Ok(NodeResponse::SessionRecordForgotten { record_id })
16792 }
16793 NodeRequest::Prompt { session, text } => {
16794 let agent_id = controlled_session(shared, connection_id, role, &session)?;
16795 validate_node_text("prompt", &text)?;
16796 shared.require_session_runtime_policy(
16797 &session,
16798 ProviderRuntimeRequirement::SemanticPrompt,
16799 )?;
16800 let framing = prompt_framing(&agent_id);
16801 shared
16802 .dispatch_input_bounded(
16803 &session,
16804 InputAction::SubmitPrompt(PromptPayload { text, framing }),
16805 )
16806 .await?;
16807 Ok(NodeResponse::Accepted)
16808 }
16809 NodeRequest::Paste { session, text } => {
16810 controlled_session(shared, connection_id, role, &session)?;
16811 validate_node_text("paste", &text)?;
16812 shared.require_session_runtime_policy(
16813 &session,
16814 ProviderRuntimeRequirement::SemanticPrompt,
16815 )?;
16816 shared
16817 .dispatch_input_bounded(
16818 &session,
16819 InputAction::InsertDraft(PromptPayload {
16820 text,
16821 framing: PromptFraming::BracketedPaste,
16822 }),
16823 )
16824 .await?;
16825 Ok(NodeResponse::Accepted)
16826 }
16827 NodeRequest::Input { session, text } => {
16828 controlled_session(shared, connection_id, role, &session)?;
16829 validate_node_text("terminal input", &text)?;
16830 shared
16831 .dispatch_input_bounded(
16832 &session,
16833 InputAction::TerminalText(TerminalText { text }),
16834 )
16835 .await?;
16836 Ok(NodeResponse::Accepted)
16837 }
16838 NodeRequest::TerminalBytes { session, bytes } => {
16839 controlled_session(shared, connection_id, role, &session)?;
16840 shared.sessionize_spawn(session.session.instance_id);
16841 let action = terminal_bytes_action(bytes)?;
16842 shared
16843 .dispatch_input_bounded(&session, action)
16844 .await?;
16845 Ok(NodeResponse::Accepted)
16846 }
16847 NodeRequest::TerminalControl { session, control } => {
16848 controlled_session(shared, connection_id, role, &session)?;
16849 shared
16850 .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
16851 .await?;
16852 Ok(NodeResponse::Accepted)
16853 }
16854 NodeRequest::Resize { session, size } => {
16855 controlled_session(shared, connection_id, role, &session)?;
16856 shared.sessionize_spawn(session.session.instance_id);
16857 if !size.is_valid() {
16858 return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
16859 }
16860 shared.dispatch_resize_bounded(&session, size).await?;
16861 Ok(NodeResponse::Accepted)
16862 }
16863 NodeRequest::Interrupt { session } => {
16864 controlled_session(shared, connection_id, role, &session)?;
16865 shared
16866 .dispatch_input_bounded(
16867 &session,
16868 InputAction::TerminalControl(TerminalControl::Interrupt),
16869 )
16870 .await?;
16871 Ok(NodeResponse::Accepted)
16872 }
16873 NodeRequest::Stop { session, force } => {
16874 controlled_session(shared, connection_id, role, &session)?;
16875 shared.sessionize_spawn(session.session.instance_id);
16876 shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
16877 shared.retire_session_grok_leader(session.session.instance_id);
16878 Ok(NodeResponse::Accepted)
16879 }
16880 NodeRequest::Remove { session } => {
16881 controlled_session(shared, connection_id, role, &session)?;
16882 shared.remove_session(&session).await?;
16883 Ok(NodeResponse::Accepted)
16884 }
16885 NodeRequest::ResolveInteraction { session, correlation_id, response } => {
16886 shared.require_controller(connection_id, role)?;
16887 let snapshot = shared.require_acp_session(&session)?;
16888 let interaction = snapshot
16889 .provider
16890 .interactions
16891 .iter()
16892 .find(|interaction| {
16893 interaction_correlation(
16894 session.session.instance_id,
16895 session.session.generation,
16896 interaction.id.0,
16897 ) == correlation_id
16898 })
16899 .ok_or_else(|| failure(
16900 NodeFailureCode::InvalidRequest,
16901 "unknown interaction correlation",
16902 ))?;
16903 if interaction.status != ProviderInteractionStatus::Pending {
16904 return Err(failure(
16905 NodeFailureCode::BackendOperationFailed,
16906 "interaction has already been resolved",
16907 ));
16908 }
16909 response
16910 .validate_for(interaction.interaction_kind)
16911 .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
16912 let interaction_id = interaction.id;
16913 let response_kind = response.kind();
16914 let response_outcome = response.outcome();
16915 shared.dispatch(ControlCommand::ResolveInteraction {
16916 instance_id: session.session.instance_id,
16917 generation: session.session.generation,
16918 interaction_id,
16919 response,
16920 })?;
16921 tracing::info!(
16927 node_id = %shared.node_id,
16928 session = ?session.session,
16929 correlation_id = %correlation_id,
16930 interaction_id = interaction_id.0,
16931 response_kind = ?response_kind,
16932 outcome = ?response_outcome,
16933 "interaction resolved",
16934 );
16935 Ok(NodeResponse::Accepted)
16936 }
16937 NodeRequest::SetSessionMode { session, mode_id } => {
16938 shared.require_controller(connection_id, role)?;
16939 shared.require_acp_session(&session)?;
16940 shared.dispatch(ControlCommand::SetSessionMode {
16941 instance_id: session.session.instance_id,
16942 mode_id,
16943 })?;
16944 Ok(NodeResponse::Accepted)
16945 }
16946 NodeRequest::SetSessionConfigOption {
16947 session,
16948 option_id,
16949 value_json,
16950 } => {
16951 shared.require_controller(connection_id, role)?;
16952 shared.require_acp_session(&session)?;
16953 shared.dispatch(ControlCommand::SetSessionConfigOption {
16954 instance_id: session.session.instance_id,
16955 option_id,
16956 value_json,
16957 })?;
16958 Ok(NodeResponse::Accepted)
16959 }
16960 NodeRequest::SetSessionModel { session, model_id } => {
16961 shared.require_controller(connection_id, role)?;
16962 shared.require_acp_session(&session)?;
16963 shared.dispatch(ControlCommand::SetSessionModel {
16964 instance_id: session.session.instance_id,
16965 model_id,
16966 })?;
16967 Ok(NodeResponse::Accepted)
16968 }
16969 NodeRequest::Shutdown => {
16970 shared.require_controller(connection_id, role)?;
16971 shared.begin_shutdown_locked().await?;
16972 Ok(NodeResponse::ShuttingDown)
16973 }
16974 }
16975}
16976
16977fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
16978 shared.require_controller(connection_id, role)?;
16979 shared.validate_address(session)
16980}
16981
16982fn select_history_candidate(
16988 candidates: &[HistoryCandidateSummary],
16989 provider_session_id: Option<&str>,
16990) -> Result<String, NodeFailure> {
16991 let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
16992 Some(id) => candidate.session_id_hint == id,
16993 None => true,
16994 });
16995 let candidate = matches.next().ok_or_else(|| {
16996 failure(
16997 NodeFailureCode::SessionRecordConflict,
16998 if provider_session_id.is_some() {
16999 "no history candidate matches the managed provider session"
17000 } else {
17001 "no history candidate exists for the managed session"
17002 },
17003 )
17004 })?;
17005 if matches.next().is_some() {
17006 return Err(failure(
17007 NodeFailureCode::SessionRecordConflict,
17008 if provider_session_id.is_some() {
17009 "history identity is ambiguous for the managed provider session"
17010 } else {
17011 "history is ambiguous without a verified provider session identity"
17012 },
17013 ));
17014 }
17015 Ok(candidate.id.clone())
17016}
17017
17018fn resolved_spawn_profile_summary(
17019 profile: &SpawnProfileDefaults,
17020 environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17021) -> Option<SpawnProfileSummary> {
17022 let environment_profile = match profile.environment_profile_id.as_ref() {
17023 None => None,
17024 Some(profile_id) => {
17025 let binding = environment_profiles.get(profile_id)?;
17026 if binding.provider != profile.provider
17027 || binding.native_profile_id(profile.mode).is_none()
17028 {
17029 return None;
17030 }
17031 Some(ResolvedEnvironmentProfileReceipt {
17032 profile_id: binding.id.clone(),
17033 profile_revision: binding.revision.clone(),
17034 network_allowlist: None,
17035 browser_profile_id: None,
17036 })
17037 }
17038 };
17039 Some(SpawnProfileSummary {
17040 id: profile.profile_id.clone(),
17041 revision: profile.revision.clone(),
17042 environment_profile,
17043 })
17044}
17045
17046fn session_record_export_target_matches(
17065 expected: &ManagedSessionRecord,
17066 current: &ManagedSessionRecord,
17067 session: &SessionAddress,
17068 allow_clean_detachment: bool,
17069) -> bool {
17070 if current == expected && current.active_session.as_ref() == Some(session) {
17071 return current.state == ManagedSessionState::Live;
17072 }
17073 allow_clean_detachment
17074 && current.state == ManagedSessionState::Dormant
17075 && current.active_session.is_none()
17076 && current.record_id == expected.record_id
17077 && current.display_name == expected.display_name
17078 && current.provider == expected.provider
17079 && current.mode == expected.mode
17080 && current.workspace_id == expected.workspace_id
17081 && current.canonical_root == expected.canonical_root
17082 && current.provider_session == expected.provider_session
17083 && current.environment_profile == expected.environment_profile
17084 && current.bundle == expected.bundle
17085 && current.context_id == expected.context_id
17086 && current.context == expected.context
17087 && current.exported_context == expected.exported_context
17088 && current.task_binding == expected.task_binding
17089 && current.created_at_unix_ms == expected.created_at_unix_ms
17090 && current.last_error == expected.last_error
17091}
17092
17093fn session_record_context_export_binding_is_exact(
17094 expected: &ManagedSessionRecord,
17095 current: &ManagedSessionRecord,
17096 identity: Option<&ProviderSessionIdentity>,
17097 session: &SessionAddress,
17098 binding: &SessionBinding,
17099 provider: &AgentId,
17100 current_root: &str,
17101 allow_clean_detachment: bool,
17102) -> bool {
17103 session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17104 && current.provider_session.as_ref() == identity
17105 && current.workspace_id == session.workspace_id
17106 && provider == ¤t.provider
17107 && binding.workspace_id == session.workspace_id
17108 && binding.generation == session.session.generation
17109 && binding.record_id.as_ref() == Some(¤t.record_id)
17110 && platform::roots_equal(
17111 current_root,
17112 windows_path_text(¤t.canonical_root),
17113 )
17114}
17115
17116fn session_record_context_export_source_is_usable(
17117 record: &ManagedSessionRecord,
17118 status: &SessionStatus,
17119 allow_clean_detachment: bool,
17120) -> bool {
17121 (record.state == ManagedSessionState::Live
17122 || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17123 && context_pack_source_status_is_usable(status)
17124}
17125
17126fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17127 PromptFraming::BracketedPaste
17128}
17129
17130fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17131 if text.len() > MAX_NODE_TEXT_BYTES {
17132 return Err(failure(
17133 NodeFailureCode::InvalidRequest,
17134 &format!(
17135 "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17136 text.len(),
17137 ),
17138 ));
17139 }
17140 Ok(())
17141}
17142
17143fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17144 if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17145 return Err(failure(
17146 NodeFailureCode::InvalidRequest,
17147 &format!(
17148 "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17149 bytes.len(),
17150 ),
17151 ));
17152 }
17153 Ok(())
17154}
17155
17156fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17157 validate_terminal_bytes(&bytes)?;
17158 Ok(InputAction::TerminalBytes(bytes))
17159}
17160
17161fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17162 if root.is_empty()
17163 || root.len() > MAX_WORKSPACE_ROOT_BYTES
17164 || root.chars().any(char::is_control)
17165 {
17166 return Err(failure(
17167 NodeFailureCode::InvalidWorkspaceRoot,
17168 &format!(
17169 "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17170 ),
17171 ));
17172 }
17173 Ok(())
17174}
17175
17176fn canonical_standalone_workspace_candidate(
17177 workspace_id: &WorkspaceId,
17178 root: &str,
17179) -> Result<String, NodeServerError> {
17180 let path = Path::new(root);
17181 match std::fs::symlink_metadata(path) {
17182 Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17183 .map(|workspace| workspace.canonical_root),
17184 Err(error) if error.kind() == io::ErrorKind::NotFound => {
17185 if !path.is_absolute() {
17186 return Err(NodeServerError::InvalidWorkspaceRoot {
17187 workspace_id: workspace_id.clone(),
17188 path: root.to_owned(),
17189 message: "path is not absolute".to_owned(),
17190 });
17191 }
17192 let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17193 workspace_id: workspace_id.clone(),
17194 path: root.to_owned(),
17195 message: "path has no standalone directory name".to_owned(),
17196 })?;
17197 let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17198 workspace_id: workspace_id.clone(),
17199 path: root.to_owned(),
17200 message: "path has no parent directory".to_owned(),
17201 })?;
17202 let canonical = std::fs::canonicalize(parent)
17203 .map(|parent| parent.join(name))
17204 .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17205 workspace_id: workspace_id.clone(),
17206 path: root.to_owned(),
17207 message: error.to_string(),
17208 })?;
17209 let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17210 NodeServerError::InvalidWorkspaceRoot {
17211 workspace_id: workspace_id.clone(),
17212 path: path.to_string_lossy().into_owned(),
17213 message: "canonical path is not valid Unicode".to_owned(),
17214 }
17215 })?;
17216 let canonical_root = platform::normalize_canonical_root(canonical_root);
17217 validate_workspace_root(workspace_id, &canonical_root)?;
17218 Ok(canonical_root)
17219 }
17220 Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17221 workspace_id: workspace_id.clone(),
17222 path: root.to_owned(),
17223 message: error.to_string(),
17224 }),
17225 }
17226}
17227
17228fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17229 let Some(revision) = revision else {
17230 return Ok(());
17231 };
17232 if revision.is_empty()
17233 || revision.len() > MAX_REPOSITORY_PATH_BYTES
17234 || revision.chars().any(char::is_control)
17235 {
17236 return Err(failure(
17237 NodeFailureCode::InvalidRequest,
17238 &format!(
17239 "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17240 ),
17241 ));
17242 }
17243 Ok(())
17244}
17245
17246fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17247 let code = match error.kind {
17248 GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17249 GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17250 GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17251 GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17252 GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17253 GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17254 GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17255 };
17256 failure(code, &error.message)
17257}
17258
17259fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17260 let code = match error.kind {
17261 StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17262 StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17263 StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17264 NodeFailureCode::BackendOperationFailed
17265 }
17266 StandaloneWorkspaceErrorKind::RecoveryRequired => {
17267 NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17268 }
17269 };
17270 failure(code, error.message)
17271}
17272
17273fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17274 let (code, message) = match error.kind {
17275 GitWorktreeErrorKind::Invalid => (
17276 NodeFailureCode::InvalidRequest,
17277 "managed Git input is invalid",
17278 ),
17279 GitWorktreeErrorKind::NotRepository => (
17280 NodeFailureCode::NotGitRepository,
17281 "managed source is not a Git repository",
17282 ),
17283 GitWorktreeErrorKind::Conflict => (
17284 NodeFailureCode::WorktreeConflict,
17285 "managed Git identity conflicts with existing repository state",
17286 ),
17287 GitWorktreeErrorKind::Protected => (
17288 NodeFailureCode::ManagedWorktreeOwnershipConflict,
17289 "managed worktree ownership validation failed",
17290 ),
17291 GitWorktreeErrorKind::Dirty => (
17292 NodeFailureCode::WorktreeDirty,
17293 "managed worktree contains uncommitted changes",
17294 ),
17295 GitWorktreeErrorKind::Locked => (
17296 NodeFailureCode::WorktreeLocked,
17297 "managed worktree is locked",
17298 ),
17299 GitWorktreeErrorKind::Failed => (
17300 NodeFailureCode::BackendOperationFailed,
17301 "managed Git operation failed",
17302 ),
17303 };
17304 NodeFailure { code, message: message.to_owned() }
17305}
17306
17307fn managed_spawn_receipt(
17308 resolved: &ResolvedSpawnSpec,
17309 incarnation_id: NodeIncarnationId,
17310 session: SessionAddress,
17311 lease: ManagedWorktreeLeaseSnapshot,
17312 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17313 bundle: Option<ResolvedBundleReceipt>,
17314 context: Option<ResolvedContextPackReceipt>,
17315) -> ManagedWorktreeSpawnReceipt {
17316 let mut managed = resolved.clone();
17317 managed.target.worktree_id = Some(lease.workspace_id.clone());
17318 ManagedWorktreeSpawnReceipt {
17319 spawn: managed.receipt_with_materialization(
17320 incarnation_id,
17321 session,
17322 environment_profile,
17323 bundle,
17324 context,
17325 ),
17326 lease,
17327 }
17328}
17329
17330fn validate_selected_worktree(
17331 source_root: &str,
17332 selected_root: &str,
17333 worktrees: &[NativeGitWorktreeSnapshot],
17334) -> Result<(), NodeFailure> {
17335 if !worktrees
17336 .iter()
17337 .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17338 {
17339 return Err(failure(
17340 NodeFailureCode::WorktreeProtected,
17341 "source workspace is not an authoritative non-bare Git worktree root",
17342 ));
17343 }
17344 if !worktrees.iter().any(|worktree| {
17345 worktree_paths_equal(&worktree.path, selected_root)
17346 && !worktree.is_main
17347 && !worktree.is_bare
17348 }) {
17349 return Err(failure(
17350 NodeFailureCode::WorktreeProtected,
17351 "selected workspace is not an eligible Git worktree in the source repository",
17352 ));
17353 }
17354 Ok(())
17355}
17356
17357fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17358 let code = match error.kind() {
17359 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17360 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17361 NodeFailureCode::RepositoryPathUnsafe
17362 }
17363 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17364 WorkspaceFileReadErrorKind::NotRegularFile => {
17365 NodeFailureCode::RepositoryFileNotRegular
17366 }
17367 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17368 NodeFailureCode::RepositoryFileReadFailed
17369 }
17370 WorkspaceFileReadErrorKind::RevisionConflict => {
17371 NodeFailureCode::RepositoryFileRevisionConflict
17372 }
17373 WorkspaceFileReadErrorKind::AlreadyExists
17374 | WorkspaceFileReadErrorKind::ParentNotFound
17375 | WorkspaceFileReadErrorKind::ParentNotDirectory
17376 | WorkspaceFileReadErrorKind::Canceled => {
17377 NodeFailureCode::RepositoryFileReadFailed
17378 }
17379 };
17380 failure(code, "repository file read failed")
17381}
17382
17383fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17384 let code = match error.kind() {
17385 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17386 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17387 NodeFailureCode::RepositoryPathUnsafe
17388 }
17389 WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17390 WorkspaceFileReadErrorKind::NotRegularFile => {
17391 NodeFailureCode::RepositoryFileNotRegular
17392 }
17393 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17394 NodeFailureCode::RepositoryFileWriteFailed
17395 }
17396 WorkspaceFileReadErrorKind::RevisionConflict => {
17397 NodeFailureCode::RepositoryFileRevisionConflict
17398 }
17399 WorkspaceFileReadErrorKind::AlreadyExists
17400 | WorkspaceFileReadErrorKind::ParentNotFound
17401 | WorkspaceFileReadErrorKind::ParentNotDirectory
17402 | WorkspaceFileReadErrorKind::Canceled => {
17403 NodeFailureCode::RepositoryFileWriteFailed
17404 }
17405 };
17406 failure(code, "repository file write failed")
17407}
17408
17409async fn settle_workspace_entry_create<T>(
17410 mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17411 commit_state: Arc<AtomicU8>,
17412 deadline: Duration,
17413 timeout_message: &'static str,
17414 task_failure_message: &'static str,
17415) -> Result<T, NodeFailure> {
17416 let joined = match timeout(deadline, &mut task).await {
17417 Ok(joined) => joined,
17418 Err(_) => {
17419 if commit_state
17420 .compare_exchange(
17421 WORKSPACE_ENTRY_CREATE_PENDING,
17422 WORKSPACE_ENTRY_CREATE_CANCELED,
17423 Ordering::AcqRel,
17424 Ordering::Acquire,
17425 )
17426 .is_ok()
17427 {
17428 return Err(failure(
17429 NodeFailureCode::RepositoryEntryCreateTimedOut,
17430 timeout_message,
17431 ));
17432 }
17433 debug_assert_eq!(
17434 commit_state.load(Ordering::Acquire),
17435 WORKSPACE_ENTRY_CREATE_COMMITTING,
17436 );
17437 task.await
17438 }
17439 };
17440 joined
17441 .map_err(|_| {
17442 failure(
17443 NodeFailureCode::RepositoryEntryCreateFailed,
17444 task_failure_message,
17445 )
17446 })?
17447 .map_err(workspace_entry_create_failure)
17448}
17449
17450fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17451 let code = match error.kind() {
17452 WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17453 WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17454 NodeFailureCode::RepositoryPathUnsafe
17455 }
17456 WorkspaceFileReadErrorKind::AlreadyExists => {
17457 NodeFailureCode::RepositoryEntryAlreadyExists
17458 }
17459 WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17460 NodeFailureCode::RepositoryParentNotFound
17461 }
17462 WorkspaceFileReadErrorKind::ParentNotDirectory
17463 | WorkspaceFileReadErrorKind::NotRegularFile => {
17464 NodeFailureCode::RepositoryParentNotDirectory
17465 }
17466 WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17467 NodeFailureCode::RepositoryEntryCreateFailed
17468 }
17469 WorkspaceFileReadErrorKind::RevisionConflict => {
17470 NodeFailureCode::RepositoryEntryCreateFailed
17471 }
17472 WorkspaceFileReadErrorKind::Canceled => {
17473 NodeFailureCode::RepositoryEntryCreateTimedOut
17474 }
17475 };
17476 failure(code, "repository entry create failed")
17477}
17478
17479fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17480 let value = digest(&SHA256, bytes)
17481 .as_ref()
17482 .iter()
17483 .map(|byte| format!("{byte:02x}"))
17484 .collect();
17485 WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17486}
17487
17488fn managed_spawn_request_digest_v2(
17489 request: &ManagedWorktreeSpawnRequestV2,
17490) -> Result<String, NodeFailure> {
17491 let encoded = serde_json::to_vec(request).map_err(|_| {
17492 failure(
17493 NodeFailureCode::InvalidRequest,
17494 "managed worktree V2 request could not be canonicalized",
17495 )
17496 })?;
17497 let mut value = String::with_capacity(71);
17498 value.push_str("sha256:");
17499 for byte in digest(&SHA256, &encoded).as_ref() {
17500 use std::fmt::Write as _;
17501 write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17502 }
17503 Ok(value)
17504}
17505
17506fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17507 let code = match error {
17508 NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17509 NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17510 NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17511 NativeSessionPreviewError::UnsupportedProvider
17512 | NativeSessionPreviewError::PreviewUnavailable
17513 | NativeSessionPreviewError::SessionNotFound
17514 | NativeSessionPreviewError::AmbiguousSession => {
17515 NodeFailureCode::BackendOperationFailed
17516 }
17517 };
17518 failure(code, "native session preview failed")
17519}
17520
17521fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17522 let code = match error.kind() {
17523 HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17524 HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17525 };
17526 failure(code, "host directory browse failed")
17527}
17528
17529fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17530 let code = match error {
17531 DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17532 DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17533 DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17534 NodeFailureCode::DeliveryStageConflict
17535 }
17536 DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17537 DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17538 NodeFailureCode::DeliveryChunkOutOfOrder
17539 }
17540 DeliveryStoreError::BlobDigestMismatch => {
17541 NodeFailureCode::DeliveryBlobDigestMismatch
17542 }
17543 DeliveryStoreError::BundleDigestMismatch => {
17544 NodeFailureCode::DeliveryBundleDigestMismatch
17545 }
17546 DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17547 DeliveryStoreError::Unavailable
17548 | DeliveryStoreError::Corrupt
17549 | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17550 };
17551 failure(code, "delivery operation failed")
17552}
17553
17554fn validate_workspace_root(
17555 workspace_id: &WorkspaceId,
17556 canonical_root: &str,
17557) -> Result<(), NodeServerError> {
17558 #[cfg(windows)]
17559 if canonical_root.starts_with(r"\\") {
17560 return Err(NodeServerError::InvalidWorkspaceRoot {
17561 workspace_id: workspace_id.clone(),
17562 path: canonical_root.to_owned(),
17563 message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17564 });
17565 }
17566 if !platform::workspace_root_supported(canonical_root)
17567 || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17568 {
17569 return Err(NodeServerError::InvalidWorkspaceRoot {
17570 workspace_id: workspace_id.clone(),
17571 path: canonical_root.to_owned(),
17572 message: "canonical path must be absolute, bounded, and free of control characters"
17573 .to_owned(),
17574 });
17575 }
17576 Ok(())
17577}
17578
17579fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
17580 NodeFailure {
17581 code,
17582 message: node_failure_category(code).to_owned(),
17583 }
17584}
17585
17586fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
17587 let code = match error {
17588 HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
17589 HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
17590 HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
17591 HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
17592 HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
17593 HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
17594 HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
17595 HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
17596 HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
17597 };
17598 failure(code, "harness MCP proxy operation failed")
17599}
17600
17601static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
17605
17606fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
17613 let trace = launch.trace.as_ref()?;
17614 let trace_dir = std::env::var(&trace.dir_env).ok()?;
17615 if trace_dir.is_empty() {
17616 return None;
17617 }
17618 let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
17619 let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
17620 Some((OsString::from(trace.env.as_str()), path.into_os_string()))
17621}
17622
17623fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
17624 let remaining = deadline.saturating_duration_since(Instant::now());
17625 if remaining.is_zero() {
17626 Err(failure(
17627 NodeFailureCode::SpawnDeadlineExceeded,
17628 "spawn deadline elapsed",
17629 ))
17630 } else {
17631 Ok(remaining)
17632 }
17633}
17634
17635fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
17636 let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
17637 match deadline {
17638 Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
17639 None => Ok(bounded),
17640 }
17641}
17642
17643fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
17644 if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
17645 failure(
17646 NodeFailureCode::SpawnDeadlineExceeded,
17647 "spawn dispatch exceeded the spawn deadline",
17648 )
17649 } else {
17650 error
17651 }
17652}
17653
17654fn spawn_runtime_capabilities(
17655 capabilities: &SpawnRequiredCapabilities,
17656) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
17657 capabilities
17658 .iter()
17659 .map(|capability| match capability.as_str() {
17660 SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
17661 SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
17662 SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
17663 SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
17664 Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
17665 }
17666 SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
17667 _ => Err(failure(
17668 NodeFailureCode::UnsupportedSpawnCapability,
17669 "spawn required capability is unknown",
17670 )),
17671 })
17672 .collect()
17673}
17674
17675fn persistence_failure(_error: io::Error) -> NodeFailure {
17676 NodeFailure {
17677 code: NodeFailureCode::BackendOperationFailed,
17678 message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
17679 }
17680}
17681
17682fn node_failure_category(code: NodeFailureCode) -> &'static str {
17683 match code {
17684 NodeFailureCode::InvalidRequest => "invalid-request",
17685 NodeFailureCode::UnsupportedCapability => "unsupported-capability",
17686 NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
17687 NodeFailureCode::ReservationNotFound => "reservation-not-found",
17688 NodeFailureCode::ReservationConflict => "reservation-conflict",
17689 NodeFailureCode::ReservationExpired => "reservation-expired",
17690 NodeFailureCode::BindingMismatch => "binding-mismatch",
17691 NodeFailureCode::NotActivated => "not-activated",
17692 NodeFailureCode::CallNotFound => "call-not-found",
17693 NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
17694 NodeFailureCode::ResponseTooLarge => "response-too-large",
17695 NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
17696 NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
17697 NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
17698 NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
17699 NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
17700 NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
17701 NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
17702 NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
17703 NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
17704 NodeFailureCode::Unauthorized => "unauthorized",
17705 NodeFailureCode::ObserverReadOnly => "observer-read-only",
17706 NodeFailureCode::ControllerBusy => "controller-busy",
17707 NodeFailureCode::ControllerRequired => "controller-required",
17708 NodeFailureCode::UnknownWorkspace => "unknown-workspace",
17709 NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
17710 NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
17711 NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
17712 NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
17713 NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
17714 NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
17715 NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
17716 NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
17717 NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
17718 NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
17719 NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
17720 NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
17721 NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
17722 NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
17723 NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
17724 NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
17725 NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
17726 NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
17727 NodeFailureCode::GitReadFailed => "git-read-failed",
17728 NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
17729 NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
17730 NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
17731 NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
17732 NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
17733 NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
17734 NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
17735 NodeFailureCode::UnsupportedTransport => "unsupported-transport",
17736 NodeFailureCode::TurnInFlight => "turn-in-flight",
17737 NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
17738 NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
17739 NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
17740 "unsupported-network-allowlist-mapping"
17741 }
17742 NodeFailureCode::BrowserStationProbeUnavailable => {
17743 "browser-station-probe-unavailable"
17744 }
17745 NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
17746 NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
17747 NodeFailureCode::EnvironmentProfileBindingMismatch => {
17748 "environment-profile-binding-mismatch"
17749 }
17750 NodeFailureCode::UnknownBundle => "unknown-bundle",
17751 NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
17752 NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
17753 NodeFailureCode::UnknownContextPack => "unknown-context-pack",
17754 NodeFailureCode::ContextPackBusy => "context-pack-busy",
17755 NodeFailureCode::ContextPackMaterializationFailed => {
17756 "context-pack-materialization-failed"
17757 }
17758 NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
17759 NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
17760 NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
17761 NodeFailureCode::WorkspaceBusy => "workspace-busy",
17762 NodeFailureCode::LastWorkspace => "last-workspace",
17763 NodeFailureCode::NotGitRepository => "not-git-repository",
17764 NodeFailureCode::WorktreeConflict => "worktree-conflict",
17765 NodeFailureCode::WorktreeProtected => "worktree-protected",
17766 NodeFailureCode::WorktreeDirty => "worktree-dirty",
17767 NodeFailureCode::WorktreeLocked => "worktree-locked",
17768 NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
17769 NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
17770 NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
17771 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
17772 "managed-worktree-profile-revision-mismatch"
17773 }
17774 NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
17775 NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
17776 "standalone-workspace-recovery-required"
17777 }
17778 NodeFailureCode::UnknownSession => "unknown-session",
17779 NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
17780 NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
17781 NodeFailureCode::SessionRecordBusy => "session-record-busy",
17782 NodeFailureCode::SessionRecordConflict => "session-record-conflict",
17783 NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
17784 NodeFailureCode::WorkspaceRegistrationRequired => {
17785 "workspace-registration-required"
17786 }
17787 NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
17788 NodeFailureCode::StaleGeneration => "stale-generation",
17789 NodeFailureCode::BackendBusy => "backend-busy",
17790 NodeFailureCode::BackendDisconnected => "backend-disconnected",
17791 NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
17792 NodeFailureCode::ShuttingDown => "shutting-down",
17793 }
17794}
17795
17796fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
17797 NodeServerError::DurableState(io::Error::new(error.kind(), category))
17798}
17799
17800fn durable_state_load_error(error: io::Error) -> NodeServerError {
17801 let category = match session_registry::state_load_refusal(&error) {
17802 Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
17803 DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
17804 }
17805 Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
17806 DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
17807 }
17808 Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
17809 DURABLE_STATE_CONFLICT_ERROR
17810 }
17811 _ => DURABLE_STATE_LOAD_FAILED_ERROR,
17812 };
17813 durable_state_server_error(error, category)
17814}
17815
17816fn unix_time_ms() -> u64 {
17817 std::time::SystemTime::now()
17818 .duration_since(std::time::UNIX_EPOCH)
17819 .unwrap_or_default()
17820 .as_millis()
17821 .min(u64::MAX as u128) as u64
17822}
17823
17824fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
17825 if record.provider_session.is_some() {
17826 ManagedSessionState::Dormant
17827 } else {
17828 ManagedSessionState::Unavailable
17829 }
17830}
17831
17832fn active_record_state(
17833 identity_admitted: bool,
17834 provider_session_is_known: bool,
17835) -> ManagedSessionState {
17836 if identity_admitted && !provider_session_is_known {
17837 ManagedSessionState::IdentityPending
17838 } else {
17839 ManagedSessionState::Live
17840 }
17841}
17842
17843fn managed_worktree_record_holders(
17844 records: &[ManagedSessionRecord],
17845) -> Vec<(SessionRecordId, WorkspaceId)> {
17846 records
17847 .iter()
17848 .filter(|record| record.provider_session.is_some())
17849 .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
17850 .collect()
17851}
17852
17853fn managed_spawn_record_ownership(
17854 binding: &SessionBinding,
17855 effective_runtime_policy: ProviderRuntimePolicy,
17856) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
17857 debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
17858 if effective_runtime_policy.provider_session_identity {
17859 (binding.record_id.clone(), None)
17860 } else {
17861 (None, binding.record_id.clone())
17862 }
17863}
17864
17865#[derive(Debug, Error)]
17866pub enum NodeServerError {
17867 #[error("node endpoint must be a bounded absolute local transport path")]
17868 InvalidEndpoint,
17869 #[error("node access token must contain 1..=4096 bytes")]
17870 InvalidAccessToken,
17871 #[error("node HTTP observer must listen on a loopback address: {0}")]
17872 InvalidApiListen(std::net::SocketAddr),
17873 #[error("node browser bridge must listen on a loopback address: {0}")]
17874 InvalidBridgeListen(std::net::SocketAddr),
17875 #[error("node browser bridge token must contain 1..=4096 bytes")]
17876 InvalidBridgeToken,
17877 #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
17878 BridgeUnderlayRequiresBridgeListen,
17879 #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
17880 BridgeUnderlayRequiresBridgeToken,
17881 #[error("mesh underlay auth token must contain 1..=4096 bytes")]
17882 InvalidBridgeUnderlayToken,
17883 #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
17884 InvalidBridgeUnderlayKey,
17885 #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
17886 InvalidCallHome(std::net::SocketAddr),
17887 #[error("node could not reach its call-home relay: {0}")]
17888 CallHomeTransport(#[source] std::io::Error),
17889 #[error("node durable state path must be an absolute file path: {0}")]
17890 InvalidStatePath(String),
17891 #[error("harness MCP helper must be an exact reviewed absolute regular file")]
17892 InvalidHarnessMcpHelper,
17893 #[error("network allowlist catalog path must be an absolute regular file: {0}")]
17894 InvalidNetworkAllowlistCatalogPath(String),
17895 #[error("network allowlist catalog could not be read: {0}")]
17896 NetworkAllowlistCatalogIo(#[source] std::io::Error),
17897 #[error("network allowlist catalog line {line}: {message}")]
17898 InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
17899 #[error("network allowlist catalog: {0}")]
17900 InvalidNetworkAllowlistCatalog(String),
17901 #[error("network allowlist catalog exceeds the {max}-entry limit")]
17902 NetworkAllowlistCatalogCapacity { max: usize },
17903 #[error("the local state directory is unavailable; supply an explicit state path")]
17904 LocalStateDirectoryUnavailable,
17905 #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
17906 LocalRuntimeDirectoryUnavailable,
17907 #[error("node durable state failed: {0}")]
17908 DurableState(io::Error),
17909 #[error("node requires at least one configured workspace")]
17910 NoWorkspaces,
17911 #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
17912 InvalidWorkspaceRoot {
17913 workspace_id: WorkspaceId,
17914 path: String,
17915 message: String,
17916 },
17917 #[error("duplicate workspace ID: {0}")]
17918 DuplicateWorkspaceId(WorkspaceId),
17919 #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
17920 DuplicateWorkspaceRoot {
17921 first: WorkspaceId,
17922 second: WorkspaceId,
17923 root: String,
17924 },
17925 #[error("workspace '{0}' uses managed worktrees but has no local profile")]
17926 ManagedWorktreeProfileRequired(WorkspaceId),
17927 #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
17928 ManagedWorktreeProfileModeMismatch(WorkspaceId),
17929 #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
17930 DuplicateManagedWorktreeProfile {
17931 workspace_id: WorkspaceId,
17932 profile_id: WorktreeProfileId,
17933 },
17934 #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
17935 ManagedWorktreeProfileCapacity {
17936 workspace_id: WorkspaceId,
17937 max: usize,
17938 },
17939 #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
17940 InvalidManagedWorktreeProfile {
17941 workspace_id: WorkspaceId,
17942 profile_id: WorktreeProfileId,
17943 message: String,
17944 },
17945 #[error("duplicate node environment profile: {0}")]
17946 DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
17947 #[error("node environment profile provider is unavailable: {0}")]
17948 EnvironmentProfileProviderUnavailable(AgentId),
17949 #[error("node environment profile capacity is {max}")]
17950 EnvironmentProfileCapacity { max: usize },
17951 #[error("node environment profiles reuse native profile ID '{0}'")]
17952 DuplicateNativeEnvironmentProfile(String),
17953 #[error("node native environment profile is invalid: {0}")]
17954 NativeEnvironmentProfile(String),
17955 #[error("node session-environment materialization root must be absolute")]
17956 InvalidSessionEnvironmentRoot,
17957 #[error("node session-environment materializer is unavailable")]
17958 SessionEnvironmentMaterializerRequired,
17959 #[error("node session-environment materializer failed to initialize")]
17960 SessionEnvironmentMaterializer,
17961 #[error("node bundle catalog is invalid: {0}")]
17962 BundleCatalog(String),
17963 #[error("node delivery store failed to initialize: {0}")]
17964 DeliveryStore(String),
17965 #[error("node context pack store failed to initialize")]
17966 ContextPackStore,
17967 #[error("active agent registry failed: {0}")]
17968 Registry(String),
17969 #[error("node provider contract manifest is invalid: {0}")]
17970 ProviderContractManifest(String),
17971 #[error("named pipe I/O failed: {0}")]
17972 Io(#[from] io::Error),
17973 #[error("node HTTP observer failed: {0}")]
17974 HttpApi(io::Error),
17975 #[error("node browser bridge failed: {0}")]
17976 BridgeApi(io::Error),
17977 #[error(transparent)]
17978 Frame(#[from] FrameError),
17979 #[error("node handshake failed: {0}")]
17980 Handshake(String),
17981 #[error("node authentication frame was not received before the bounded deadline")]
17982 AuthenticationTimedOut,
17983 #[error("node authentication primitive failed: {0}")]
17984 Authentication(String),
17985 #[error("node incarnation identity generation failed: {0}")]
17986 IncarnationIdentity(String),
17987 #[error("node pre-authentication limiter is closed")]
17988 PreauthClosed,
17989 #[error("node authenticated connection limit was reached")]
17990 AuthenticatedConnectionLimit,
17991 #[error("node Ctrl+C/CtrlBreak signal stream closed")]
17992 SignalStreamClosed,
17993 #[error("node stop-all dispatch failed: {0:?}")]
17994 ShutdownDispatch(NodeFailure),
17995 #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
17996 ShutdownTimedOut { active_native_sessions: usize },
17997}
17998
17999impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18000 fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18001 use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18002 match error {
18003 E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18004 E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18005 E::InvalidEntry { line, message } => {
18006 Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18007 }
18008 E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18009 E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18010 }
18011 }
18012}
18013
18014#[cfg(test)]
18015mod agent_stream_blocked_tests {
18016 use super::*;
18017 use gate4agent_types::{
18018 AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18019 ProviderSource,
18020 };
18021
18022 fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18023 ProviderSource {
18024 family,
18025 binding: AdapterBinding::new(
18026 AdapterId::new(adapter).unwrap(),
18027 "fixture/v1",
18028 AdapterVerification::SyntheticFixture,
18029 )
18030 .unwrap(),
18031 }
18032 }
18033
18034 fn provider_control_event_at(
18035 family: AdapterFamily,
18036 adapter: &str,
18037 source_sequence: u64,
18038 provider_event: ProviderEvent,
18039 ) -> ControlEvent {
18040 ControlEvent {
18041 sequence: 41,
18042 command_id: None,
18043 instance_id: AgentInstanceId(7),
18044 generation: SessionGeneration(3),
18045 event: ControlEventKind::ProviderEvent {
18046 sequence: 8,
18047 source: provider_source(family, adapter),
18048 source_sequence,
18049 event: provider_event,
18050 },
18051 }
18052 }
18053
18054 fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18055 provider_control_event_at(family, "fixture", 9, provider_event)
18056 }
18057
18058 fn denied_host_request(
18059 by: ProviderHostDecisionAuthority,
18060 reason: Option<String>,
18061 ) -> ControlEvent {
18062 provider_control_event_at(
18063 AdapterFamily::Acp,
18064 "claude-code",
18065 2,
18066 ProviderEvent::HostRequestObserved {
18067 method: "terminal/create".to_owned(),
18068 params_json: String::new(),
18069 decision: ProviderHostRequestDecision::Denied { by },
18070 outcome: ProviderHostRequestOutcome::Executed,
18071 reason,
18072 },
18073 )
18074 }
18075
18076 fn blocked(
18077 event: &ControlEvent,
18078 ) -> (
18079 Option<String>,
18080 String,
18081 BlockAuthorityV1,
18082 Option<String>,
18083 String,
18084 Option<String>,
18085 ) {
18086 let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18087 chunk.validate().expect("minted chunk must validate");
18088 let AgentStreamChunkKindV1::Blocked {
18089 correlation_id,
18090 tool_class,
18091 authority,
18092 reason_kind,
18093 reason,
18094 help,
18095 } = chunk.kind
18096 else {
18097 panic!("expected AgentStreamChunkKindV1::Blocked");
18098 };
18099 (correlation_id, tool_class, authority, reason_kind, reason, help)
18100 }
18101
18102 #[test]
18106 fn the_enabled_provider_matrix_is_exact() {
18107 let registry = active_registry().unwrap();
18108 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
18109 assert_eq!(
18110 providers
18111 .iter()
18112 .map(|contract| contract.provider.as_str())
18113 .collect::<Vec<_>>(),
18114 vec!["shell", "claude", "codex", "grok", "kimi"],
18116 );
18117 assert!(adapters.iter().all(|contract| {
18118 !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18119 }));
18120 }
18121
18122 #[test]
18125 fn gate_denied_host_request_mints_a_blocked_chunk() {
18126 let gate_text =
18127 "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18128 let event = denied_host_request(
18129 ProviderHostDecisionAuthority::Gate,
18130 Some(gate_text.to_owned()),
18131 );
18132 let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18133 assert_eq!(chunk.source_sequence, 2);
18134 let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18135 assert_eq!(authority, BlockAuthorityV1::HostGate);
18136 assert_eq!(reason, gate_text);
18137 assert_eq!(correlation_id, None);
18138 assert_eq!(reason_kind, None);
18139 assert_eq!(help, None);
18140 assert_eq!(tool_class, "Write");
18141 }
18142
18143 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.";
18144
18145 #[test]
18151 fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18152 let event = provider_control_event(
18153 AdapterFamily::Acp,
18154 ProviderEvent::ToolCompleted {
18155 id: "toolu_9".to_owned(),
18156 output: CLASSIFIER_SAMPLE.to_owned(),
18157 is_error: true,
18158 duration_ms: None,
18159 agent_id: None,
18160 non_execution_kind: Some("permission-rule".to_owned()),
18161 },
18162 );
18163 let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18164 assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18165 assert_eq!(reason, "Blocked by classifier");
18166 assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18167 assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18168 assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18169 }
18170
18171 #[test]
18176 fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18177 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.";
18178 let event = provider_control_event(
18179 AdapterFamily::Acp,
18180 ProviderEvent::SessionEnded {
18181 result: "JSON-RPC error -32603: Internal error".to_owned(),
18182 cost_usd: None,
18183 is_error: true,
18184 stop_reason: Some(ProviderStopReason::ProviderError {
18185 code: -32603,
18186 message: message.to_owned(),
18187 vendor_code: Some("usageLimitExceeded".to_owned()),
18188 }),
18189 },
18190 );
18191 let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18192 assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18193 assert_eq!(reason, message);
18194 assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18195 assert_eq!(tool_class, "Session");
18196 }
18197
18198 #[test]
18201 fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18202 let event = provider_control_event(
18203 AdapterFamily::Acp,
18204 ProviderEvent::SessionEnded {
18205 result: "refusal".to_owned(),
18206 cost_usd: None,
18207 is_error: true,
18208 stop_reason: Some(ProviderStopReason::Refusal),
18209 },
18210 );
18211 let (_, _, authority, _, _, _) = blocked(&event);
18212 assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18213 }
18214
18215 #[test]
18219 fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18220 for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18221 let event = provider_control_event(
18222 AdapterFamily::Acp,
18223 ProviderEvent::ToolCompleted {
18224 id: "toolu_1".to_owned(),
18225 output: "command exited with status 1".to_owned(),
18226 is_error: true,
18227 duration_ms: Some(12),
18228 agent_id: None,
18229 non_execution_kind,
18230 },
18231 );
18232 assert!(agent_stream_chunk(&event).is_none());
18233 }
18234 let narrative = provider_control_event(
18235 AdapterFamily::Acp,
18236 ProviderEvent::ToolCompleted {
18237 id: "toolu_2".to_owned(),
18238 output: "the harness blocked the write, approval needed".to_owned(),
18239 is_error: false,
18240 duration_ms: Some(3),
18241 agent_id: None,
18242 non_execution_kind: None,
18243 },
18244 );
18245 assert!(agent_stream_chunk(&narrative).is_none());
18246 }
18247
18248 #[test]
18252 fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18253 for (decision, outcome) in [
18254 (
18255 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18256 ProviderHostRequestOutcome::Executed,
18257 ),
18258 (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18259 (
18260 ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18261 ProviderHostRequestOutcome::Failed {
18262 error: "terminal/create spawn failed: os error 3".to_owned(),
18263 },
18264 ),
18265 ] {
18266 let event = provider_control_event_at(
18267 AdapterFamily::Acp,
18268 "claude-code",
18269 3,
18270 ProviderEvent::HostRequestObserved {
18271 method: "fs/read_text_file".to_owned(),
18272 params_json: String::new(),
18273 decision: decision.clone(),
18274 outcome,
18275 reason: None,
18276 },
18277 );
18278 assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18279 }
18280 }
18281
18282 #[test]
18286 fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18287 for (by, expected_authority, expected_reason) in [
18288 (
18289 ProviderHostDecisionAuthority::Operator,
18290 BlockAuthorityV1::Operator,
18291 "declined by the operator",
18292 ),
18293 (
18294 ProviderHostDecisionAuthority::DeadlinePolicy,
18295 BlockAuthorityV1::HostDeadline,
18296 "denied by host policy after the operator deadline expired",
18297 ),
18298 ] {
18299 let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18300 assert_eq!(authority, expected_authority);
18301 assert_eq!(reason, expected_reason);
18302 }
18303 }
18304
18305 #[test]
18309 fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18310 let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18311 let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18312 ProviderHostDecisionAuthority::Policy,
18313 Some(oversized),
18314 ));
18315 assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18316 }
18317}
18318
18319#[cfg(test)]
18320mod standalone_capability_platform_tests {
18321 use super::*;
18322
18323 #[test]
18324 fn standalone_workspace_capability_requires_windows_identity_guards() {
18325 let capabilities = baseline_capabilities().unwrap();
18326 let advertised = capabilities.iter().any(|capability| {
18327 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18328 });
18329 assert_eq!(advertised, cfg!(windows));
18330
18331 let request = NodeRequest::CreateStandaloneWorkspace {
18332 workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18333 root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18334 initial_branch: None,
18335 };
18336 assert_eq!(
18337 request_uses_unnegotiated_capability(&request, &capabilities),
18338 !cfg!(windows),
18339 );
18340 }
18341}
18342
18343
18344#[cfg(test)]
18345mod station_knobs_resolve_tests {
18346 use super::*;
18347 use gate4agent_catalog::EnvMutation;
18348 use gate4agent_runtime_native::{
18349 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18350 NativeLaunchProfileId,
18351 };
18352 use std::ffi::OsString;
18353 use std::sync::Arc;
18354
18355 struct EmptyEnvironmentResolver;
18356
18357 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18358 fn resolve_child_environment(
18359 &self,
18360 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18361 Ok(vec![EnvMutation {
18362 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18363 value: None,
18364 }])
18365 }
18366 }
18367
18368 fn agent(value: &str) -> AgentId {
18369 AgentId::new(value).unwrap()
18370 }
18371
18372 fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18373 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18374 let profile_revision =
18375 crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18376 let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18377 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18378 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18379 provider: agent("claude"),
18380 mode: SessionMode::Pty,
18381 terminal_size: gate4agent_types::TerminalSize {
18382 rows: 24,
18383 columns: 80,
18384 },
18385 prompt: None,
18386 bundle_id: None,
18387 context_id: None,
18388 environment_profile_id: Some(profile_id.clone()),
18389 };
18390 let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18391 let catalog = active_registry().unwrap();
18392 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18393 let control = runtime.native_launch_profile_control();
18394 let node_profile = NodeEnvironmentProfile::new(
18395 profile_id.clone(),
18396 profile_revision,
18397 agent("claude"),
18398 [NativeLaunchProfile::new(
18399 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18400 agent("claude"),
18401 TransportKind::Pty,
18402 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18403 Arc::new(EmptyEnvironmentResolver),
18404 )
18405 .unwrap()],
18406 )
18407 .unwrap();
18408 let (binding, native_profiles, _materialization) = node_profile.into_parts();
18409 for native_profile in native_profiles {
18410 runtime.upsert_native_launch_profile(native_profile).unwrap();
18411 }
18412 let workspace = WorkspaceConfig::new(
18413 WorkspaceId::new("primary").unwrap(),
18414 std::env::current_dir().unwrap(),
18415 )
18416 .unwrap();
18417 let shared = NodeShared::new_with_incarnation(
18418 handle,
18419 "fixture-token".to_owned(),
18420 NodeId::new("node-station-knobs-test").unwrap(),
18421 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18422 vec![workspace],
18423 vec![agent("claude")],
18424 ProviderRuntimeStatuses::default(),
18425 None,
18426 Vec::new(),
18427 Vec::new(),
18428 spawn_profiles,
18429 Some(control),
18430 None,
18431 None,
18432 None,
18433 Vec::new(),
18434 Vec::new(),
18435 Vec::new(),
18436 Vec::new(),
18437 None,
18438 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18439 );
18440 shared
18441 .environment_profiles
18442 .write()
18443 .unwrap_or_else(|poisoned| poisoned.into_inner())
18444 .insert(binding.id.clone(), binding);
18445 (shared, spawn_defaults)
18446 }
18447
18448 #[test]
18451 fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18452 let (shared, defaults) = station_knobs_fixture();
18453 let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18454 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18455 let spec = crate::protocol::SpawnSpec {
18456 target: crate::protocol::SpawnTarget {
18457 node_id: shared.node_id.clone(),
18458 workspace_id: WorkspaceId::new("primary").unwrap(),
18459 worktree_id: None,
18460 },
18461 profile_id: defaults.profile_id.clone(),
18462 expected_profile_revision: defaults.revision.clone(),
18463 overrides: crate::protocol::SpawnOverrides {
18464 network_allowlist: Some(unknown),
18465 browser_profile_id: Some(browser),
18466 ..crate::protocol::SpawnOverrides::default()
18467 },
18468 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18469 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18470 "station-knobs-unknown-allowlist",
18471 )
18472 .unwrap(),
18473 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18474 };
18475 let resolved = spec.resolve(&defaults).unwrap();
18476 let failure = shared
18477 .resolve_environment_profile(&resolved)
18478 .expect_err("unknown allowlist must refuse");
18479 assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18480 }
18481
18482 #[test]
18488 fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18489 let (shared, defaults) = station_knobs_fixture();
18490 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18491 #[cfg(not(feature = "dig2-station-probe"))]
18492 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18493 shared
18494 .network_allowlist_catalog
18495 .write()
18496 .unwrap_or_else(|poisoned| poisoned.into_inner())
18497 .insert_id(allowlist.clone())
18498 .unwrap();
18499 let spec = crate::protocol::SpawnSpec {
18500 target: crate::protocol::SpawnTarget {
18501 node_id: shared.node_id.clone(),
18502 workspace_id: WorkspaceId::new("primary").unwrap(),
18503 worktree_id: None,
18504 },
18505 profile_id: defaults.profile_id.clone(),
18506 expected_profile_revision: defaults.revision.clone(),
18507 overrides: crate::protocol::SpawnOverrides {
18508 network_allowlist: Some(allowlist.clone()),
18509 #[cfg(not(feature = "dig2-station-probe"))]
18510 browser_profile_id: Some(browser.clone()),
18511 ..crate::protocol::SpawnOverrides::default()
18512 },
18513 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18514 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18515 .unwrap(),
18516 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18517 };
18518 let resolved = spec.resolve(&defaults).unwrap();
18519 assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18520 #[cfg(not(feature = "dig2-station-probe"))]
18521 assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18522 let receipt = shared
18523 .resolve_environment_profile(&resolved)
18524 .expect("resolve ok")
18525 .expect("env profile present");
18526 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18527 #[cfg(not(feature = "dig2-station-probe"))]
18528 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18529 #[cfg(feature = "dig2-station-probe")]
18530 assert_eq!(receipt.browser_profile_id, None);
18531 assert_eq!(receipt.profile_id.as_str(), "local-claude");
18532
18533 let omitted = crate::protocol::SpawnSpec {
18534 overrides: crate::protocol::SpawnOverrides::default(),
18535 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18536 .unwrap(),
18537 ..spec
18538 };
18539 let resolved_omit = omitted.resolve(&defaults).unwrap();
18540 let bare = shared
18541 .resolve_environment_profile(&resolved_omit)
18542 .expect("resolve ok")
18543 .expect("env profile present");
18544 assert_eq!(bare.network_allowlist, None);
18545 assert_eq!(bare.browser_profile_id, None);
18546 }
18547
18548 #[cfg(feature = "dig2-station-probe")]
18553 #[test]
18554 fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18555 let (shared, defaults) = station_knobs_fixture();
18556 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18557 let spec = crate::protocol::SpawnSpec {
18558 target: crate::protocol::SpawnTarget {
18559 node_id: shared.node_id.clone(),
18560 workspace_id: WorkspaceId::new("primary").unwrap(),
18561 worktree_id: None,
18562 },
18563 profile_id: defaults.profile_id.clone(),
18564 expected_profile_revision: defaults.revision.clone(),
18565 overrides: crate::protocol::SpawnOverrides {
18566 browser_profile_id: Some(browser),
18567 ..crate::protocol::SpawnOverrides::default()
18568 },
18569 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18570 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18571 "station-knobs-probe-refuse",
18572 )
18573 .unwrap(),
18574 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18575 };
18576 let resolved = spec.resolve(&defaults).unwrap();
18577 let failure = shared
18578 .resolve_environment_profile(&resolved)
18579 .expect_err("browser_profile with failing probe must refuse");
18580 #[cfg(windows)]
18581 {
18582 assert!(
18583 matches!(
18584 failure.code,
18585 NodeFailureCode::BrowserStationUnreachable
18586 | NodeFailureCode::BrowserStationProbeUnavailable
18587 ),
18588 "unexpected code {:?}",
18589 failure.code
18590 );
18591 }
18592 #[cfg(not(windows))]
18593 {
18594 assert_eq!(
18595 failure.code,
18596 NodeFailureCode::BrowserStationProbeUnavailable
18597 );
18598 }
18599 }
18600
18601 #[cfg(feature = "dig2-station-probe")]
18603 #[test]
18604 fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
18605 let (shared, defaults) = station_knobs_fixture();
18606 let spec = crate::protocol::SpawnSpec {
18607 target: crate::protocol::SpawnTarget {
18608 node_id: shared.node_id.clone(),
18609 workspace_id: WorkspaceId::new("primary").unwrap(),
18610 worktree_id: None,
18611 },
18612 profile_id: defaults.profile_id.clone(),
18613 expected_profile_revision: defaults.revision.clone(),
18614 overrides: crate::protocol::SpawnOverrides::default(),
18615 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18616 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18617 "station-knobs-probe-omit",
18618 )
18619 .unwrap(),
18620 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18621 };
18622 let resolved = spec.resolve(&defaults).unwrap();
18623 let receipt = shared
18624 .resolve_environment_profile(&resolved)
18625 .expect("omit browser_profile must not probe")
18626 .expect("env profile present");
18627 assert_eq!(receipt.browser_profile_id, None);
18628 }
18629
18630 #[cfg(feature = "dig2-station-probe")]
18634 #[test]
18635 fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
18636 let (shared, _defaults) = station_knobs_fixture();
18637 let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18638 let workspace_id = WorkspaceId::new("primary").unwrap();
18639 let address_a = SessionAddress {
18640 workspace_id: workspace_id.clone(),
18641 session: SessionKey {
18642 instance_id: AgentInstanceId(101),
18643 generation: SessionGeneration(1),
18644 },
18645 };
18646 let address_b = SessionAddress {
18647 workspace_id: workspace_id.clone(),
18648 session: SessionKey {
18649 instance_id: AgentInstanceId(102),
18650 generation: SessionGeneration(1),
18651 },
18652 };
18653 let guard_a = crate::dig2_station_lease::acquire_lease(
18654 &shared.browser_station_leases,
18655 profile.clone(),
18656 address_a.session.instance_id,
18657 1,
18658 )
18659 .expect("first lease");
18660 let busy = crate::dig2_station_lease::acquire_lease(
18661 &shared.browser_station_leases,
18662 profile.clone(),
18663 address_b.session.instance_id,
18664 2,
18665 )
18666 .expect_err("second holder must refuse");
18667 assert!(matches!(
18668 busy,
18669 crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
18670 ));
18671 shared.bind_session_with_policy(
18673 &address_a,
18674 ProviderRuntimePolicy::raw_pty(),
18675 None,
18676 );
18677 guard_a.retain();
18678 assert!(shared.remove_binding(&address_a).is_some());
18679 crate::dig2_station_lease::acquire_lease(
18680 &shared.browser_station_leases,
18681 profile,
18682 address_b.session.instance_id,
18683 3,
18684 )
18685 .expect("reacquire after clear")
18686 .retain();
18687 assert!(
18688 crate::dig2_station_lease::release_lease_for_holder(
18689 &shared.browser_station_leases,
18690 address_b.session.instance_id,
18691 )
18692 );
18693 }
18694
18695 #[cfg(not(feature = "dig2-station-probe"))]
18698 #[test]
18699 fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
18700 let (shared, defaults) = station_knobs_fixture();
18701 let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18702 let spec = crate::protocol::SpawnSpec {
18703 target: crate::protocol::SpawnTarget {
18704 node_id: shared.node_id.clone(),
18705 workspace_id: WorkspaceId::new("primary").unwrap(),
18706 worktree_id: None,
18707 },
18708 profile_id: defaults.profile_id.clone(),
18709 expected_profile_revision: defaults.revision.clone(),
18710 overrides: crate::protocol::SpawnOverrides {
18711 browser_profile_id: Some(browser.clone()),
18712 ..crate::protocol::SpawnOverrides::default()
18713 },
18714 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18715 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
18716 .unwrap(),
18717 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18718 };
18719 let resolved = spec.resolve(&defaults).unwrap();
18720 let receipt = shared
18721 .resolve_environment_profile(&resolved)
18722 .expect("stub resolve")
18723 .expect("env profile");
18724 assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18725 }
18726
18727
18728 #[test]
18731 fn network_allowlist_catalog_loads_from_temp_file() {
18732 let unique = std::time::SystemTime::now()
18733 .duration_since(std::time::UNIX_EPOCH)
18734 .unwrap()
18735 .as_nanos();
18736 let path = std::env::temp_dir().join(format!(
18737 "gate4agent-node-allowlist-config-{}-{unique}.txt",
18738 std::process::id(),
18739 ));
18740 std::fs::write(
18741 &path,
18742 "# opaque station allowlist ids\negress-default\nlab-egress\n",
18743 )
18744 .unwrap();
18745 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
18746 .expect("temp catalog file must load");
18747 let _ = std::fs::remove_file(&path);
18748 assert_eq!(loaded.len(), 2);
18749 let (shared, defaults) = station_knobs_fixture();
18750 {
18751 let mut catalog = shared
18752 .network_allowlist_catalog
18753 .write()
18754 .unwrap_or_else(|poisoned| poisoned.into_inner());
18755 *catalog = loaded;
18756 }
18757 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18758 let spec = crate::protocol::SpawnSpec {
18759 target: crate::protocol::SpawnTarget {
18760 node_id: shared.node_id.clone(),
18761 workspace_id: WorkspaceId::new("primary").unwrap(),
18762 worktree_id: None,
18763 },
18764 profile_id: defaults.profile_id.clone(),
18765 expected_profile_revision: defaults.revision.clone(),
18766 overrides: crate::protocol::SpawnOverrides {
18767 network_allowlist: Some(allowlist.clone()),
18768 ..crate::protocol::SpawnOverrides::default()
18769 },
18770 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18771 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18772 "station-knobs-catalog-file",
18773 )
18774 .unwrap(),
18775 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18776 };
18777 let resolved = spec.resolve(&defaults).unwrap();
18778 let receipt = shared
18779 .resolve_environment_profile(&resolved)
18780 .expect("registered via file load")
18781 .expect("env profile present");
18782 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18783 }
18784
18785 #[test]
18788 fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
18789 let (shared, _defaults) = station_knobs_fixture();
18790 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18791 shared
18792 .network_allowlist_catalog
18793 .write()
18794 .unwrap_or_else(|poisoned| poisoned.into_inner())
18795 .insert_id(allowlist.clone())
18796 .unwrap();
18797 let snapshot = shared.snapshot();
18798 let inventory = snapshot
18799 .launch_inventory
18800 .expect("launch inventory present");
18801 let ids = inventory
18802 .network_allowlists
18803 .expect("non-empty catalog surfaces ids");
18804 assert_eq!(ids, vec![allowlist]);
18805 }
18806
18807 #[test]
18811 fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
18812 let (shared, defaults) = station_knobs_fixture();
18813 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
18814 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18815 id: allowlist.clone(),
18816 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
18817 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18818 peer: "127.0.0.1:443".to_owned(),
18819 }],
18820 provider_native: Some(
18821 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18822 codex_network_access: Some(true),
18823 },
18824 ),
18825 };
18826 shared
18827 .network_allowlist_catalog
18828 .write()
18829 .unwrap_or_else(|poisoned| poisoned.into_inner())
18830 .insert(entry)
18831 .unwrap();
18832 let spec = crate::protocol::SpawnSpec {
18833 target: crate::protocol::SpawnTarget {
18834 node_id: shared.node_id.clone(),
18835 workspace_id: WorkspaceId::new("primary").unwrap(),
18836 worktree_id: None,
18837 },
18838 profile_id: defaults.profile_id.clone(),
18839 expected_profile_revision: defaults.revision.clone(),
18840 overrides: crate::protocol::SpawnOverrides {
18841 network_allowlist: Some(allowlist),
18842 ..crate::protocol::SpawnOverrides::default()
18843 },
18844 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18845 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18846 "station-knobs-unsupported-mapping",
18847 )
18848 .unwrap(),
18849 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18850 };
18851 let resolved = spec.resolve(&defaults).unwrap();
18852 assert_eq!(resolved.provider.as_str(), "claude");
18853 let failure = shared
18854 .resolve_environment_profile(&resolved)
18855 .expect_err("claude + codex_network_access must refuse");
18856 assert_eq!(
18857 failure.code,
18858 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18859 );
18860 }
18861
18862 #[test]
18866 fn resolve_codex_network_access_overlay_moderate_only() {
18867 let (shared, defaults) = station_knobs_fixture();
18868 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
18869 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18870 id: allowlist.clone(),
18871 permits: Vec::new(),
18872 provider_native: Some(
18873 crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
18874 codex_network_access: Some(true),
18875 },
18876 ),
18877 };
18878 shared
18879 .network_allowlist_catalog
18880 .write()
18881 .unwrap_or_else(|poisoned| poisoned.into_inner())
18882 .insert(entry)
18883 .unwrap();
18884
18885 let overlay = shared
18886 .resolve_codex_network_access_overlay_args(
18887 &agent("codex"),
18888 SessionMode::Pty,
18889 ApprovalLevel::Moderate,
18890 Some(&allowlist),
18891 )
18892 .expect("Moderate Codex maps network_access");
18893 assert_eq!(
18894 overlay,
18895 vec![
18896 std::ffi::OsString::from("-c"),
18897 std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
18898 ]
18899 );
18900
18901 let acp = shared
18902 .resolve_codex_network_access_overlay_args(
18903 &agent("codex"),
18904 SessionMode::Acp,
18905 ApprovalLevel::Moderate,
18906 Some(&allowlist),
18907 )
18908 .unwrap();
18909 assert_eq!(acp, overlay);
18910
18911 let inline = shared
18912 .resolve_codex_network_access_overlay_args(
18913 &agent("codex"),
18914 SessionMode::Inline,
18915 ApprovalLevel::Moderate,
18916 Some(&allowlist),
18917 )
18918 .unwrap_err();
18919 assert_eq!(
18920 inline.code,
18921 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18922 );
18923
18924 for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
18925 let spec = crate::protocol::SpawnSpec {
18926 target: crate::protocol::SpawnTarget {
18927 node_id: shared.node_id.clone(),
18928 workspace_id: WorkspaceId::new("primary").unwrap(),
18929 worktree_id: None,
18930 },
18931 profile_id: defaults.profile_id.clone(),
18932 expected_profile_revision: defaults.revision.clone(),
18933 overrides: crate::protocol::SpawnOverrides {
18934 provider: crate::protocol::SpawnOverride::Set {
18935 value: agent("codex"),
18936 },
18937 approval_level: Some(level),
18938 network_allowlist: Some(allowlist.clone()),
18939 ..crate::protocol::SpawnOverrides::default()
18940 },
18941 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18942 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
18943 "codex-net-refuse-{}",
18944 match level {
18945 ApprovalLevel::ReadOnly => "ro",
18946 ApprovalLevel::FullAuto => "fa",
18947 _ => "other",
18948 }
18949 ))
18950 .unwrap(),
18951 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18952 };
18953 let resolved = spec.resolve(&defaults).unwrap();
18954 let failure = shared
18955 .resolve_environment_profile(&resolved)
18956 .expect_err("RO/FullAuto must refuse");
18957 assert_eq!(
18958 failure.code,
18959 NodeFailureCode::UnsupportedNetworkAllowlistMapping
18960 );
18961 }
18962 }
18963
18964 #[test]
18968 fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
18969 let (shared, _defaults) = station_knobs_fixture();
18970 let allowlist =
18971 crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
18972 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
18973 id: allowlist.clone(),
18974 permits: vec![
18975 crate::network_allowlist_catalog::NetworkPermitSketch {
18976 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18977 peer: "github.com:443".to_owned(),
18978 },
18979 crate::network_allowlist_catalog::NetworkPermitSketch {
18980 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
18981 peer: "*.npmjs.org".to_owned(),
18982 },
18983 ],
18984 provider_native: None,
18985 };
18986 shared
18987 .network_allowlist_catalog
18988 .write()
18989 .unwrap_or_else(|poisoned| poisoned.into_inner())
18990 .insert(entry)
18991 .unwrap();
18992
18993 let unique = std::time::SystemTime::now()
18994 .duration_since(std::time::UNIX_EPOCH)
18995 .unwrap()
18996 .as_nanos();
18997 let provider_home = std::env::temp_dir().join(format!(
18998 "gate4agent-claude-provider-home-{}-{unique}",
18999 std::process::id(),
19000 ));
19001 std::fs::create_dir_all(&provider_home).unwrap();
19002
19003 let overlay = shared
19004 .resolve_network_allowlist_overlay_args(
19005 &agent("claude"),
19006 SessionMode::Pty,
19007 ApprovalLevel::Moderate,
19008 Some(&allowlist),
19009 Some(provider_home.as_path()),
19010 )
19011 .expect("Claude PTY settings overlay on unix");
19012 let settings_path = provider_home.join(
19013 crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19014 );
19015 assert_eq!(
19016 overlay,
19017 vec![
19018 std::ffi::OsString::from("--settings"),
19019 std::ffi::OsString::from(settings_path.as_os_str()),
19020 ]
19021 );
19022 let body = std::fs::read_to_string(&settings_path).unwrap();
19023 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19024 assert_eq!(value["sandbox"]["enabled"], true);
19025 assert_eq!(
19026 value["sandbox"]["network"]["allowedDomains"],
19027 serde_json::json!(["github.com", "*.npmjs.org"])
19028 );
19029 assert!(settings_path.starts_with(&provider_home));
19030
19031 let acp = shared
19032 .resolve_network_allowlist_overlay_args(
19033 &agent("claude"),
19034 SessionMode::Acp,
19035 ApprovalLevel::Moderate,
19036 Some(&allowlist),
19037 Some(provider_home.as_path()),
19038 )
19039 .expect_err("Claude ACP must refuse settings overlay");
19040 assert_eq!(
19041 acp.code,
19042 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19043 );
19044
19045 let no_home = shared
19046 .resolve_network_allowlist_overlay_args(
19047 &agent("claude"),
19048 SessionMode::Pty,
19049 ApprovalLevel::Moderate,
19050 Some(&allowlist),
19051 None,
19052 )
19053 .expect_err("Claude overlay requires ProviderHome");
19054 assert_eq!(
19055 no_home.code,
19056 NodeFailureCode::UnsupportedNetworkAllowlistMapping
19057 );
19058
19059 let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19060 shared
19061 .network_allowlist_catalog
19062 .write()
19063 .unwrap_or_else(|poisoned| poisoned.into_inner())
19064 .insert_id(bare.clone())
19065 .unwrap();
19066 let empty = shared
19067 .resolve_network_allowlist_overlay_args(
19068 &agent("claude"),
19069 SessionMode::Pty,
19070 ApprovalLevel::Moderate,
19071 Some(&bare),
19072 Some(provider_home.as_path()),
19073 )
19074 .unwrap();
19075 assert!(empty.is_empty());
19076
19077 let _ = std::fs::remove_dir_all(&provider_home);
19078 }
19079
19080 #[test]
19082 fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19083 let (shared, _defaults) = station_knobs_fixture();
19084 let allowlist =
19085 crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19086 let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19087 id: allowlist.clone(),
19088 permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19089 protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19090 peer: "example.com:443".to_owned(),
19091 }],
19092 provider_native: None,
19093 };
19094 shared
19095 .network_allowlist_catalog
19096 .write()
19097 .unwrap_or_else(|poisoned| poisoned.into_inner())
19098 .insert(entry)
19099 .unwrap();
19100 for provider in ["kimi", "grok"] {
19101 let overlay = shared
19102 .resolve_network_allowlist_overlay_args(
19103 &agent(provider),
19104 SessionMode::Pty,
19105 ApprovalLevel::Moderate,
19106 Some(&allowlist),
19107 None,
19108 )
19109 .unwrap();
19110 assert!(
19111 overlay.is_empty(),
19112 "{provider} must not invent network argv from permits"
19113 );
19114 }
19115 }
19116
19117 #[test]
19119 fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19120 let unique = std::time::SystemTime::now()
19121 .duration_since(std::time::UNIX_EPOCH)
19122 .unwrap()
19123 .as_nanos();
19124 let path = std::env::temp_dir().join(format!(
19125 "gate4agent-node-allowlist-json-{}-{unique}.json",
19126 std::process::id(),
19127 ));
19128 std::fs::write(
19129 &path,
19130 r#"{
19131 "schema_version": 2,
19132 "entries": [
19133 {
19134 "id": "egress-default",
19135 "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19136 },
19137 { "id": "lab-egress" }
19138 ]
19139 }"#,
19140 )
19141 .unwrap();
19142 let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19143 .expect("json v2 catalog must load");
19144 let _ = std::fs::remove_file(&path);
19145 assert_eq!(loaded.len(), 2);
19146 let entry = loaded
19147 .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19148 .unwrap();
19149 assert_eq!(entry.permits.len(), 1);
19150 let (shared, defaults) = station_knobs_fixture();
19151 {
19152 let mut catalog = shared
19153 .network_allowlist_catalog
19154 .write()
19155 .unwrap_or_else(|poisoned| poisoned.into_inner());
19156 *catalog = loaded;
19157 }
19158 let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19159 let spec = crate::protocol::SpawnSpec {
19160 target: crate::protocol::SpawnTarget {
19161 node_id: shared.node_id.clone(),
19162 workspace_id: WorkspaceId::new("primary").unwrap(),
19163 worktree_id: None,
19164 },
19165 profile_id: defaults.profile_id.clone(),
19166 expected_profile_revision: defaults.revision.clone(),
19167 overrides: crate::protocol::SpawnOverrides {
19168 network_allowlist: Some(allowlist.clone()),
19169 ..crate::protocol::SpawnOverrides::default()
19170 },
19171 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19172 idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19173 "station-knobs-catalog-json",
19174 )
19175 .unwrap(),
19176 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19177 };
19178 let resolved = spec.resolve(&defaults).unwrap();
19179 let receipt = shared
19180 .resolve_environment_profile(&resolved)
19181 .expect("permits-only entry ok for claude")
19182 .expect("env profile present");
19183 assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19184 let inventory = shared
19185 .snapshot()
19186 .launch_inventory
19187 .expect("launch inventory present");
19188 let ids = inventory.network_allowlists.expect("ids surface");
19189 assert_eq!(
19190 ids,
19191 vec![
19192 crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19193 crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19194 ]
19195 );
19196 }
19197
19198
19199}
19200
19201
19202#[cfg(test)]
19203mod workspace_environment_collision_tests {
19204 use super::*;
19205 use crate::session_environment::{
19206 NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19207 };
19208 use gate4agent_catalog::EnvMutation;
19209 use gate4agent_runtime_native::{
19210 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19211 NativeLaunchProfileId,
19212 };
19213 use std::ffi::OsString;
19214 use std::sync::Arc;
19215
19216 struct EmptyEnvironmentResolver;
19217
19218 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19219 fn resolve_child_environment(
19220 &self,
19221 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19222 Ok(vec![EnvMutation {
19223 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19224 value: None,
19225 }])
19226 }
19227 }
19228
19229 fn agent(value: &str) -> AgentId {
19230 AgentId::new(value).unwrap()
19231 }
19232
19233 fn claude_config_dir_collision_fixture() -> (
19235 NodeShared,
19236 ResolvedEnvironmentProfileReceipt,
19237 ResolvedEnvironmentProfileReceipt,
19238 ) {
19239 let catalog = active_registry().unwrap();
19240 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19241 let control = runtime.native_launch_profile_control();
19242 let workspace = WorkspaceConfig::new(
19243 WorkspaceId::new("primary").unwrap(),
19244 std::env::current_dir().unwrap(),
19245 )
19246 .unwrap();
19247 let shared = NodeShared::new_with_incarnation(
19248 handle,
19249 "fixture-token".to_owned(),
19250 NodeId::new("node-env-collision-test").unwrap(),
19251 NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19252 vec![workspace],
19253 vec![agent("claude")],
19254 ProviderRuntimeStatuses::default(),
19255 None,
19256 Vec::new(),
19257 Vec::new(),
19258 SpawnProfileRegistry::default(),
19259 Some(control),
19260 None,
19261 None,
19262 None,
19263 Vec::new(),
19264 Vec::new(),
19265 Vec::new(),
19266 Vec::new(),
19267 None,
19268 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19269 );
19270
19271 let mut receipts = Vec::new();
19272 for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19273 let profile_id =
19274 SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19275 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19276 "claude-config-dir-{suffix}-r1"
19277 ))
19278 .unwrap();
19279 let materialization = NodeSessionMaterializationProfile::new(
19280 Vec::new(),
19281 vec![NodeSessionPathBinding::new(
19282 "CLAUDE_CONFIG_DIR",
19283 NodeSessionPathClass::ProviderHome,
19284 )
19285 .unwrap()],
19286 Vec::new(),
19287 )
19288 .unwrap();
19289 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19290 profile_id.clone(),
19291 profile_revision.clone(),
19292 agent("claude"),
19293 [NativeLaunchProfile::new(
19294 NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19295 .unwrap(),
19296 agent("claude"),
19297 TransportKind::Pty,
19298 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19299 Arc::new(EmptyEnvironmentResolver),
19300 )
19301 .unwrap()],
19302 Some(materialization),
19303 )
19304 .unwrap();
19305 let (binding, native_profiles, materialization) = node_profile.into_parts();
19306 for native_profile in native_profiles {
19307 runtime.upsert_native_launch_profile(native_profile).unwrap();
19308 }
19309 shared
19310 .environment_profiles
19311 .write()
19312 .unwrap_or_else(|poisoned| poisoned.into_inner())
19313 .insert(binding.id.clone(), binding);
19314 shared
19315 .environment_materialization_profiles
19316 .write()
19317 .unwrap_or_else(|poisoned| poisoned.into_inner())
19318 .insert(profile_id.clone(), materialization.unwrap());
19319 receipts.push(ResolvedEnvironmentProfileReceipt {
19320 profile_id,
19321 profile_revision,
19322 network_allowlist: None,
19323 browser_profile_id: None,
19324 });
19325 }
19326 let profile_b = receipts.pop().unwrap();
19327 let profile_a = receipts.pop().unwrap();
19328 (shared, profile_a, profile_b)
19329 }
19330
19331 #[test]
19332 fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19333 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19334 let primary = WorkspaceId::new("primary").unwrap();
19335 let worktree = WorkspaceId::new("worktree-b").unwrap();
19336 let address = SessionAddress {
19337 workspace_id: primary.clone(),
19338 session: SessionKey {
19339 instance_id: AgentInstanceId(41),
19340 generation: SessionGeneration(1),
19341 },
19342 };
19343 shared.bind_session_with_policy(
19344 &address,
19345 ProviderRuntimePolicy::raw_pty(),
19346 Some(profile_a.clone()),
19347 );
19348
19349 let refused = shared
19350 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19351 .unwrap_err();
19352 assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19353
19354 let refused_none = shared
19356 .ensure_workspace_environment_profile_compatible(&primary, None)
19357 .unwrap_err();
19358 assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19359
19360 shared
19362 .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19363 .unwrap();
19364
19365 shared
19367 .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19368 .unwrap();
19369 }
19370
19371 #[tokio::test]
19372 async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19373 let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19374 let primary = WorkspaceId::new("primary").unwrap();
19375 let address = SessionAddress {
19376 workspace_id: primary.clone(),
19377 session: SessionKey {
19378 instance_id: AgentInstanceId(41),
19379 generation: SessionGeneration(1),
19380 },
19381 };
19382 shared.bind_session_with_policy(
19383 &address,
19384 ProviderRuntimePolicy::raw_pty(),
19385 Some(profile_a),
19386 );
19387
19388 let failure = shared
19389 .spawn_session_with_deadline(
19390 primary,
19391 agent("claude"),
19392 SessionMode::Pty,
19393 gate4agent_types::TerminalSize {
19394 rows: 24,
19395 columns: 80,
19396 },
19397 None,
19398 Some(profile_b),
19399 None,
19400 None,
19401 None,
19402 None,
19403 SpawnRecordPolicy::ProviderIdentityOnly,
19404 Some(Instant::now() + Duration::from_secs(30)),
19405 &[],
19406 None,
19407 ApprovalLevel::default(),
19408 None,
19409 None,
19410 )
19411 .await
19412 .unwrap_err();
19413 assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19414 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19416 assert!(shared.handle.snapshot().sessions.is_empty());
19417 }
19418}
19419
19420#[cfg(test)]
19421mod spawn_characterization_tests {
19422 use super::*;
19423 use crate::session_environment::{
19424 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19425 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19426 NodeSessionPathClass,
19427 };
19428 use gate4agent_catalog::EnvMutation;
19429 use gate4agent_runtime_native::{
19430 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19431 NativeLaunchProfile, NativeLaunchProfileId,
19432 };
19433 use std::ffi::OsString;
19434 use std::sync::Arc;
19435
19436 struct EmptyEnvironmentResolver;
19437
19438 struct FixtureSecretResolver {
19439 deny: Arc<AtomicBool>,
19440 }
19441
19442 impl NodeSecretResolver for FixtureSecretResolver {
19443 fn resolve(
19444 &self,
19445 _reference: &NodeSecretReference,
19446 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19447 if self.deny.load(Ordering::Acquire) {
19448 Err(NodeSecretResolveError::Denied)
19449 } else {
19450 NodeSecretValue::text("fixture-secret")
19451 .map_err(|_| NodeSecretResolveError::Unavailable)
19452 }
19453 }
19454 }
19455
19456 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19457 fn resolve_child_environment(
19458 &self,
19459 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19460 Ok(vec![EnvMutation {
19461 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19462 value: None,
19463 }])
19464 }
19465 }
19466
19467 fn agent(value: &str) -> AgentId {
19468 AgentId::new(value).unwrap()
19469 }
19470
19471 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19472 let unique = std::time::SystemTime::now()
19473 .duration_since(std::time::UNIX_EPOCH)
19474 .unwrap()
19475 .as_nanos();
19476 std::env::temp_dir().join(format!(
19477 "gate4agent-node-{label}-{}-{unique}",
19478 std::process::id(),
19479 ))
19480 }
19481
19482 fn materialization_test_shared(
19483 deny_secret: bool,
19484 ) -> (
19485 NodeShared,
19486 NativeRuntime,
19487 ResolvedEnvironmentProfileReceipt,
19488 PathBuf,
19489 Arc<AtomicBool>,
19490 ) {
19491 let workspace = WorkspaceConfig::new(
19492 WorkspaceId::new("primary").unwrap(),
19493 std::env::current_dir().unwrap(),
19494 )
19495 .unwrap();
19496 materialization_test_shared_with_workspace(deny_secret, workspace)
19497 }
19498
19499 fn materialization_test_shared_with_workspace(
19500 deny_secret: bool,
19501 workspace: WorkspaceConfig,
19502 ) -> (
19503 NodeShared,
19504 NativeRuntime,
19505 ResolvedEnvironmentProfileReceipt,
19506 PathBuf,
19507 Arc<AtomicBool>,
19508 ) {
19509 let root = temporary_workspace_root(if deny_secret {
19510 "materialization-denied"
19511 } else {
19512 "materialization-ready"
19513 });
19514 std::fs::create_dir_all(&root).unwrap();
19515 let materialization_root = root.join("session-environments");
19516 let deny = Arc::new(AtomicBool::new(deny_secret));
19517 let resolver = Arc::new(FixtureSecretResolver {
19518 deny: Arc::clone(&deny),
19519 });
19520 let materializer = SessionEnvironmentMaterializer::new(
19521 materialization_root,
19522 resolver,
19523 )
19524 .unwrap();
19525 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19526 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19527 "materialized-claude-r1",
19528 )
19529 .unwrap();
19530 let materialization = NodeSessionMaterializationProfile::new(
19531 vec![NodeSessionEnvironmentMutation::SetSecret {
19532 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19533 reference: NodeSecretReference::new("fixture-token").unwrap(),
19534 }],
19535 vec![NodeSessionPathBinding::new(
19536 "GATE4AGENT_PROVIDER_HOME",
19537 NodeSessionPathClass::ProviderHome,
19538 )
19539 .unwrap()],
19540 vec![NodeSessionFile::secret(
19541 NodeSessionPathClass::Config,
19542 "auth/token",
19543 NodeSecretReference::new("fixture-token").unwrap(),
19544 )
19545 .unwrap()],
19546 )
19547 .unwrap();
19548 let catalog = active_registry().unwrap();
19549 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19550 let control = runtime.native_launch_profile_control();
19551 let node_profile = NodeEnvironmentProfile::new_with_materialization(
19552 profile_id.clone(),
19553 profile_revision.clone(),
19554 agent("claude"),
19555 [NativeLaunchProfile::new(
19556 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19557 agent("claude"),
19558 TransportKind::Pty,
19559 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19560 Arc::new(EmptyEnvironmentResolver),
19561 )
19562 .unwrap()],
19563 Some(materialization),
19564 )
19565 .unwrap();
19566 let (binding, native_profiles, materialization) = node_profile.into_parts();
19567 for native_profile in native_profiles {
19568 runtime.upsert_native_launch_profile(native_profile).unwrap();
19569 }
19570 let shared = NodeShared::new_with_incarnation(
19571 handle,
19572 "fixture-token".to_owned(),
19573 NodeId::new("node-materialization-test").unwrap(),
19574 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19575 vec![workspace],
19576 vec![agent("claude")],
19577 ProviderRuntimeStatuses::default(),
19578 None,
19579 Vec::new(),
19580 Vec::new(),
19581 SpawnProfileRegistry::default(),
19582 Some(control),
19583 None,
19584 Some(materializer),
19585 None,
19586 Vec::new(),
19587 Vec::new(),
19588 Vec::new(),
19589 Vec::new(),
19590 None,
19591 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19592 );
19593 shared
19594 .environment_profiles
19595 .write()
19596 .unwrap_or_else(|poisoned| poisoned.into_inner())
19597 .insert(binding.id.clone(), binding);
19598 shared
19599 .environment_materialization_profiles
19600 .write()
19601 .unwrap_or_else(|poisoned| poisoned.into_inner())
19602 .insert(profile_id.clone(), materialization.unwrap());
19603 (
19604 shared,
19605 runtime,
19606 ResolvedEnvironmentProfileReceipt {
19607 profile_id,
19608 profile_revision,
19609 network_allowlist: None,
19610 browser_profile_id: None,
19611 },
19612 root,
19613 deny,
19614 )
19615 }
19616
19617
19618 async fn characterization_spawn(
19621 shared: &NodeShared,
19622 mode: SessionMode,
19623 environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
19624 required: &[ProviderRuntimeCapability],
19625 harness_mcp: Option<&PreparedHarnessMcpSpawn>,
19626 ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
19627 shared
19628 .spawn_session_with_deadline(
19629 WorkspaceId::new("primary").unwrap(),
19630 agent("claude"),
19631 mode,
19632 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
19633 None,
19634 environment_profile,
19635 None,
19636 None,
19637 None,
19638 Some(ProviderRuntimePolicy::raw_pty()),
19639 SpawnRecordPolicy::ProviderIdentityOnly,
19640 Some(Instant::now() + Duration::from_secs(30)),
19641 required,
19642 harness_mcp,
19643 ApprovalLevel::default(),
19644 None,
19645 None,
19646 )
19647 .await
19648 }
19649
19650 fn assert_no_session_state(shared: &NodeShared) {
19651 assert!(shared.handle.snapshot().sessions.is_empty());
19652 assert!(shared
19653 .session_bindings
19654 .lock()
19655 .unwrap_or_else(|poisoned| poisoned.into_inner())
19656 .is_empty());
19657 assert!(shared
19658 .materializations
19659 .lock()
19660 .unwrap_or_else(|poisoned| poisoned.into_inner())
19661 .is_empty());
19662 }
19663
19664 #[tokio::test]
19665 async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
19666 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19667 let error = characterization_spawn(
19668 &shared,
19669 SessionMode::Pty,
19670 None,
19671 &[ProviderRuntimeCapability::SemanticReadiness],
19672 None,
19673 )
19674 .await
19675 .unwrap_err();
19676 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19677 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19678 assert_no_session_state(&shared);
19679 drop(shared);
19680 drop(runtime);
19681 std::fs::remove_dir_all(root).unwrap();
19682 }
19683
19684 #[tokio::test]
19685 async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
19686 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
19687 let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
19688 .await
19689 .unwrap_err();
19690 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
19691 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
19692 assert_no_session_state(&shared);
19693 drop(shared);
19694 drop(runtime);
19695 std::fs::remove_dir_all(root).unwrap();
19696 }
19697
19698 #[test]
19699 fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
19700 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
19701 let address = SessionAddress {
19702 workspace_id: WorkspaceId::new("primary").unwrap(),
19703 session: SessionKey {
19704 instance_id: AgentInstanceId(77),
19705 generation: SessionGeneration(1),
19706 },
19707 };
19708 shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
19709 let removed = shared.remove_binding(&address).expect("binding removed");
19710 assert!(removed.environment_profile.is_some());
19711 assert!(shared.remove_binding(&address).is_none());
19712 assert_no_session_state(&shared);
19713 drop(shared);
19714 drop(runtime);
19715 std::fs::remove_dir_all(root).unwrap();
19716 }
19717
19718 async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
19719 use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
19720 use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
19721 let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
19722 .expect("review current test executable as fixture helper");
19723 let registry = HarnessMcpProxyRegistry::new(helper);
19724 let spec = crate::protocol::SpawnSpec {
19725 target: crate::protocol::SpawnTarget {
19726 node_id: NodeId::new("node-characterization").unwrap(),
19727 workspace_id: WorkspaceId::new("primary").unwrap(),
19728 worktree_id: None,
19729 },
19730 profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
19731 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
19732 overrides: crate::protocol::SpawnOverrides::default(),
19733 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19734 idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
19735 required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19736 };
19737 let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
19738 let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
19739 let launch = HarnessMcpLaunchV1 {
19740 server_name: "fixture-harness".to_owned(),
19741 args: vec!["--session-proxy".to_owned()],
19742 endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
19743 token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
19744 program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
19745 trace: None,
19746 scrub_env: Vec::new(),
19747 };
19748 let expires = unix_time_ms().saturating_add(30_000);
19749 registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
19750 registry
19751 .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
19752 .unwrap()
19753 }
19754
19755 #[tokio::test]
19756 async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
19757 let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19758 let prepared = characterization_prepared_harness("claude").await;
19759 let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
19760 .await
19761 .unwrap_err();
19762 assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
19764 let mismatched = characterization_prepared_harness("codex").await;
19765 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
19766 .await
19767 .unwrap_err();
19768 assert_eq!(error.code, NodeFailureCode::BindingMismatch);
19769 assert_no_session_state(&shared);
19770 drop(shared);
19771 drop(runtime);
19772 std::fs::remove_dir_all(root).unwrap();
19773 }
19774
19775 #[tokio::test]
19776 async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
19777 let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
19778 shared.native_launch_profile_control = None;
19779 let prepared = characterization_prepared_harness("claude").await;
19780 let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
19781 .await
19782 .unwrap_err();
19783 assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
19784 assert_no_session_state(&shared);
19785 drop(shared);
19786 drop(runtime);
19787 std::fs::remove_dir_all(root).unwrap();
19788 }
19789}
19790
19791#[cfg(all(test, windows))]
19792mod tests {
19793 use super::*;
19794 use gate4agent_types::ProviderActivity;
19795 use crate::session_environment::{
19796 NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19797 NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19798 NodeSessionPathClass,
19799 };
19800 use gate4agent_catalog::EnvMutation;
19801 use gate4agent_runtime_native::{
19802 NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19803 NativeLaunchProfile, NativeLaunchProfileId,
19804 };
19805 use std::ffi::OsString;
19806 use std::sync::Arc;
19807
19808 struct EmptyEnvironmentResolver;
19809
19810 struct FixtureSecretResolver {
19811 deny: Arc<AtomicBool>,
19812 }
19813
19814 impl NodeSecretResolver for FixtureSecretResolver {
19815 fn resolve(
19816 &self,
19817 _reference: &NodeSecretReference,
19818 ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19819 if self.deny.load(Ordering::Acquire) {
19820 Err(NodeSecretResolveError::Denied)
19821 } else {
19822 NodeSecretValue::text("fixture-secret")
19823 .map_err(|_| NodeSecretResolveError::Unavailable)
19824 }
19825 }
19826 }
19827
19828 impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19829 fn resolve_child_environment(
19830 &self,
19831 ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19832 Ok(vec![EnvMutation {
19833 key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19834 value: None,
19835 }])
19836 }
19837 }
19838
19839 fn agent(value: &str) -> AgentId {
19840 AgentId::new(value).unwrap()
19841 }
19842
19843 fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
19844 let catalog = active_registry().unwrap();
19845 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19846 let workspace = WorkspaceConfig::new(
19847 WorkspaceId::new("primary").unwrap(),
19848 std::env::current_dir().unwrap(),
19849 )
19850 .unwrap()
19851 .with_worktree_service_mode(mode);
19852 NodeShared::new(
19853 handle,
19854 "fixture-token".to_owned(),
19855 NodeId::new("node-terminal-test").unwrap(),
19856 vec![workspace],
19857 vec![agent("claude")],
19858 )
19859 }
19860
19861 fn terminal_test_shared() -> NodeShared {
19862 terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
19863 }
19864
19865 #[cfg(feature = "fixture")]
19866 #[test]
19867 fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
19868 let probe = SpawnManagedWorktreeV2FailureProbe::default();
19869 assert_eq!(probe.latest_code(), None);
19870
19871 probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
19872 assert_eq!(
19873 probe.latest_code(),
19874 Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
19875 );
19876
19877 probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
19878 assert_eq!(
19879 probe.latest_code(),
19880 Some(NodeFailureCode::SpawnDeadlineExceeded),
19881 );
19882
19883 probe.record(None);
19884 assert_eq!(probe.latest_code(), None);
19885 }
19886
19887 #[test]
19888 fn launch_snapshot_is_authoritative_bounded_and_path_free() {
19889 let source = temporary_workspace_root("launch-inventory-source");
19890 let allocation = temporary_workspace_root("launch-inventory-allocation");
19891 std::fs::create_dir_all(&source).unwrap();
19892 std::fs::create_dir_all(&allocation).unwrap();
19893 let workspace_id = WorkspaceId::new("primary").unwrap();
19894 let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
19895 .unwrap()
19896 .with_worktree_service_mode(WorktreeServiceMode::Managed);
19897 for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
19898 workspace = workspace
19899 .with_managed_worktree_profile(
19900 ManagedWorktreeProfile::new(
19901 WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
19902 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19903 &allocation,
19904 "gate4agent",
19905 "HEAD",
19906 ManagedWorktreeRetention::RemoveWhenReleased,
19907 )
19908 .unwrap(),
19909 )
19910 .unwrap();
19911 }
19912 let overflow = workspace.clone().with_managed_worktree_profile(
19913 ManagedWorktreeProfile::new(
19914 WorktreeProfileId::new("overflow").unwrap(),
19915 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
19916 &allocation,
19917 "gate4agent",
19918 "HEAD",
19919 ManagedWorktreeRetention::Retain,
19920 )
19921 .unwrap(),
19922 );
19923 assert!(matches!(
19924 overflow,
19925 Err(NodeServerError::ManagedWorktreeProfileCapacity {
19926 max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19927 ..
19928 }),
19929 ));
19930
19931 let catalog = active_registry().unwrap();
19932 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19933 let shared = NodeShared::new(
19934 handle,
19935 "fixture-token".to_owned(),
19936 NodeId::new("node-launch-inventory").unwrap(),
19937 vec![workspace],
19938 vec![agent("claude")],
19939 );
19940 let snapshot = shared.snapshot();
19941 let launch = snapshot.launch_inventory.as_ref().unwrap();
19942 assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
19943 assert!(launch.bundles.as_ref().unwrap().is_empty());
19944 let profiles = snapshot.workspaces[0]
19945 .managed_worktree_profiles
19946 .as_ref()
19947 .unwrap();
19948 assert_eq!(
19949 snapshot.workspaces[0].worktree_service_mode,
19950 Some(WorktreeServiceMode::Managed),
19951 );
19952 assert_eq!(
19953 profiles.profiles.len(),
19954 MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
19955 );
19956 let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
19957 for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
19958 assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
19959 }
19960 let legacy = snapshot_for_wire(
19961 &shared,
19962 true,
19963 true,
19964 false,
19965 false,
19966 true,
19967 false,
19968 true,
19969 false,
19970 true,
19971 );
19972 assert!(legacy.launch_inventory.is_none());
19973 assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
19974
19975 drop(shared);
19976 std::fs::remove_dir_all(source).unwrap();
19977 std::fs::remove_dir_all(allocation).unwrap();
19978 }
19979
19980 #[test]
19981 fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
19982 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
19983 capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
19984 }));
19985 let source = gate4agent_types::ProviderSource {
19986 family: AdapterFamily::PtySemantic,
19987 binding: AdapterBinding::new(
19988 gate4agent_types::AdapterId::new("claude").unwrap(),
19989 "fixture/v1",
19990 gate4agent_types::AdapterVerification::SyntheticFixture,
19991 )
19992 .unwrap(),
19993 };
19994 let provider = ProviderSnapshot {
19995 sequence: 17,
19996 session: Some(ProviderSessionIdentity {
19997 key: ProviderSessionKey::SessionId,
19998 id: "PRIVATE_PROVIDER_SESSION".to_owned(),
19999 transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20000 }),
20001 model: Some("PRIVATE_MODEL".to_owned()),
20002 tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20003 completed_turns: 3,
20004 usage: gate4agent_types::TokenUsage {
20005 input_tokens: 10,
20006 output_tokens: 20,
20007 cache_read_tokens: 30,
20008 cache_write_tokens: 40,
20009 reasoning_tokens: 50,
20010 context_window: Some(200_000),
20011 },
20012 lead_activity: ProviderActivity::WaitingForInput,
20013 activity: ProviderActivity::WaitingForInput,
20014 current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20015 active_tools: (0..9)
20016 .map(|index| gate4agent_types::ActiveProviderTool {
20017 id: format!("private-tool-id-{index}"),
20018 name: if index == 0 {
20019 format!(" Read\u{0000}{}", "x".repeat(80))
20020 } else {
20021 format!("Tool-{index}")
20022 },
20023 input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20024 })
20025 .collect(),
20026 interactions: vec![gate4agent_types::ProviderInteraction {
20027 id: gate4agent_types::ProviderInteractionId(17),
20028 source: source.clone(),
20029 provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20030 interaction_kind: ProviderInteractionKind::Approval,
20031 tool_name: " Approve\u{0007}Tool ".to_owned(),
20032 prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20033 agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20034 resume_lead_activity: Some(ProviderActivity::Working),
20035 status: ProviderInteractionStatus::Pending,
20036 }],
20037 subagents: vec![gate4agent_types::ProviderSubagent {
20038 source: source.clone(),
20039 provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20040 agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20041 description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20042 }],
20043 sources: vec![gate4agent_types::ProviderSourceCursor {
20044 source,
20045 sequence: 17,
20046 gap_count: 2,
20047 stale: true,
20048 }],
20049 last_event: Some(ProviderEvent::Error {
20050 message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20051 }),
20052 gap_count: 2,
20053 stale: true,
20054 };
20055 let address = terminal_address(3);
20056 let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20057 assert_eq!(entry.address, address);
20058 assert_eq!(entry.progress.provider_sequence, 17);
20059 assert_eq!(entry.progress.active_tool_count, 9);
20060 assert_eq!(
20061 entry.progress.active_tool_labels.len(),
20062 MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20063 );
20064 assert_eq!(entry.progress.subagent_count, 1);
20065 assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20066 assert!(entry.progress.truncated);
20067 let encoded = serde_json::to_string(&entry).unwrap();
20068 for forbidden in [
20069 "PRIVATE_PROVIDER_SESSION",
20070 "PRIVATE_PROVIDER_PATH",
20071 "PRIVATE_MODEL",
20072 "PRIVATE_TOOL_INVENTORY",
20073 "PRIVATE_CURRENT_PROMPT",
20074 "PRIVATE_TOOL_INPUT_JSON",
20075 "PRIVATE_PROVIDER_REQUEST",
20076 "PRIVATE_INTERACTION_PROMPT",
20077 "PRIVATE_INTERACTION_AGENT",
20078 "PRIVATE_SUBAGENT_ID",
20079 "PRIVATE_SUBAGENT_TYPE",
20080 "PRIVATE_SUBAGENT_DESCRIPTION",
20081 "PRIVATE_PROVIDER_ERROR",
20082 "input_json",
20083 "current_prompt",
20084 "session_id",
20085 ] {
20086 assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20087 }
20088
20089 let snapshot = NodeSnapshot {
20090 node_id: NodeId::new("node-progress-test").unwrap(),
20091 enabled_providers: vec![agent("claude")],
20092 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20093 workspaces: Vec::new(),
20094 session_records: Vec::new(),
20095 managed_worktrees: Vec::new(),
20096 launch_inventory: None,
20097 agent_progress: vec![entry],
20098 };
20099 let mut legacy_reply = ResponseEnvelope {
20100 request_id: 1,
20101 result: Ok(NodeResponse::Snapshot {
20102 event_sequence: 0,
20103 controller: None,
20104 snapshot: snapshot.clone(),
20105 }),
20106 };
20107 project_response_without_agent_progress(&mut legacy_reply);
20108 let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20109 panic!("agent progress projection changed response kind");
20110 };
20111 assert!(legacy.agent_progress.is_empty());
20112 assert_eq!(snapshot.agent_progress.len(), 1);
20113 }
20114
20115 fn terminal_address(generation: u64) -> SessionAddress {
20116 SessionAddress {
20117 workspace_id: WorkspaceId::new("primary").unwrap(),
20118 session: SessionKey {
20119 instance_id: AgentInstanceId(41),
20120 generation: SessionGeneration(generation),
20121 },
20122 }
20123 }
20124
20125 fn spawn_spec_fixture() -> SpawnSpec {
20126 SpawnSpec {
20127 target: crate::protocol::SpawnTarget {
20128 node_id: NodeId::new("node-terminal-test").unwrap(),
20129 workspace_id: WorkspaceId::new("primary").unwrap(),
20130 worktree_id: None,
20131 },
20132 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20133 expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20134 .unwrap(),
20135 overrides: crate::protocol::SpawnOverrides::default(),
20136 deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20137 idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20138 required_capabilities: SpawnRequiredCapabilities::default(),
20139 }
20140 }
20141
20142 fn environment_profile_test_shared(
20143 ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20144 let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20145 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20146 "local-claude-r1",
20147 )
20148 .unwrap();
20149 let spawn_profiles = SpawnProfileRegistry::new([
20150 crate::protocol::SpawnProfileDefaults {
20151 profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20152 revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20153 provider: agent("claude"),
20154 mode: SessionMode::Pty,
20155 terminal_size: gate4agent_types::TerminalSize {
20156 rows: 24,
20157 columns: 80,
20158 },
20159 prompt: None,
20160 bundle_id: None,
20161 context_id: None,
20162 environment_profile_id: Some(profile_id.clone()),
20163 },
20164 ])
20165 .unwrap();
20166 let catalog = active_registry().unwrap();
20167 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20168 let control = runtime.native_launch_profile_control();
20169 let node_profile = NodeEnvironmentProfile::new(
20170 profile_id.clone(),
20171 profile_revision.clone(),
20172 agent("claude"),
20173 [NativeLaunchProfile::new(
20174 NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20175 agent("claude"),
20176 TransportKind::Pty,
20177 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20178 Arc::new(EmptyEnvironmentResolver),
20179 )
20180 .unwrap()],
20181 )
20182 .unwrap();
20183 let (binding, native_profiles, _materialization) = node_profile.into_parts();
20184 for native_profile in native_profiles {
20185 runtime.upsert_native_launch_profile(native_profile).unwrap();
20186 }
20187 let workspace = WorkspaceConfig::new(
20188 WorkspaceId::new("primary").unwrap(),
20189 std::env::current_dir().unwrap(),
20190 )
20191 .unwrap();
20192 let shared = NodeShared::new_with_incarnation(
20193 handle,
20194 "fixture-token".to_owned(),
20195 NodeId::new("node-terminal-test").unwrap(),
20196 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20197 vec![workspace],
20198 vec![agent("claude")],
20199 ProviderRuntimeStatuses::default(),
20200 None,
20201 Vec::new(),
20202 Vec::new(),
20203 spawn_profiles,
20204 Some(control),
20205 None,
20206 None,
20207 None,
20208 Vec::new(),
20209 Vec::new(),
20210 Vec::new(),
20211 Vec::new(),
20212 None,
20213 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20214 );
20215 shared
20216 .environment_profiles
20217 .write()
20218 .unwrap_or_else(|poisoned| poisoned.into_inner())
20219 .insert(binding.id.clone(), binding);
20220 (
20221 shared,
20222 runtime,
20223 ResolvedEnvironmentProfileReceipt {
20224 profile_id,
20225 profile_revision,
20226 network_allowlist: None,
20227 browser_profile_id: None,
20228 },
20229 )
20230 }
20231
20232 fn materialization_test_shared(
20233 deny_secret: bool,
20234 ) -> (
20235 NodeShared,
20236 NativeRuntime,
20237 ResolvedEnvironmentProfileReceipt,
20238 PathBuf,
20239 Arc<AtomicBool>,
20240 ) {
20241 let workspace = WorkspaceConfig::new(
20242 WorkspaceId::new("primary").unwrap(),
20243 std::env::current_dir().unwrap(),
20244 )
20245 .unwrap();
20246 materialization_test_shared_with_workspace(deny_secret, workspace)
20247 }
20248
20249 fn materialization_test_shared_with_workspace(
20250 deny_secret: bool,
20251 workspace: WorkspaceConfig,
20252 ) -> (
20253 NodeShared,
20254 NativeRuntime,
20255 ResolvedEnvironmentProfileReceipt,
20256 PathBuf,
20257 Arc<AtomicBool>,
20258 ) {
20259 let root = temporary_workspace_root(if deny_secret {
20260 "materialization-denied"
20261 } else {
20262 "materialization-ready"
20263 });
20264 std::fs::create_dir_all(&root).unwrap();
20265 let materialization_root = root.join("session-environments");
20266 let deny = Arc::new(AtomicBool::new(deny_secret));
20267 let resolver = Arc::new(FixtureSecretResolver {
20268 deny: Arc::clone(&deny),
20269 });
20270 let materializer = SessionEnvironmentMaterializer::new(
20271 materialization_root,
20272 resolver,
20273 )
20274 .unwrap();
20275 let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20276 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20277 "materialized-claude-r1",
20278 )
20279 .unwrap();
20280 let materialization = NodeSessionMaterializationProfile::new(
20281 vec![NodeSessionEnvironmentMutation::SetSecret {
20282 key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20283 reference: NodeSecretReference::new("fixture-token").unwrap(),
20284 }],
20285 vec![NodeSessionPathBinding::new(
20286 "GATE4AGENT_PROVIDER_HOME",
20287 NodeSessionPathClass::ProviderHome,
20288 )
20289 .unwrap()],
20290 vec![NodeSessionFile::secret(
20291 NodeSessionPathClass::Config,
20292 "auth/token",
20293 NodeSecretReference::new("fixture-token").unwrap(),
20294 )
20295 .unwrap()],
20296 )
20297 .unwrap();
20298 let catalog = active_registry().unwrap();
20299 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20300 let control = runtime.native_launch_profile_control();
20301 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20302 profile_id.clone(),
20303 profile_revision.clone(),
20304 agent("claude"),
20305 [NativeLaunchProfile::new(
20306 NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20307 agent("claude"),
20308 TransportKind::Pty,
20309 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20310 Arc::new(EmptyEnvironmentResolver),
20311 )
20312 .unwrap()],
20313 Some(materialization),
20314 )
20315 .unwrap();
20316 let (binding, native_profiles, materialization) = node_profile.into_parts();
20317 for native_profile in native_profiles {
20318 runtime.upsert_native_launch_profile(native_profile).unwrap();
20319 }
20320 let shared = NodeShared::new_with_incarnation(
20321 handle,
20322 "fixture-token".to_owned(),
20323 NodeId::new("node-materialization-test").unwrap(),
20324 NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20325 vec![workspace],
20326 vec![agent("claude")],
20327 ProviderRuntimeStatuses::default(),
20328 None,
20329 Vec::new(),
20330 Vec::new(),
20331 SpawnProfileRegistry::default(),
20332 Some(control),
20333 None,
20334 Some(materializer),
20335 None,
20336 Vec::new(),
20337 Vec::new(),
20338 Vec::new(),
20339 Vec::new(),
20340 None,
20341 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20342 );
20343 shared
20344 .environment_profiles
20345 .write()
20346 .unwrap_or_else(|poisoned| poisoned.into_inner())
20347 .insert(binding.id.clone(), binding);
20348 shared
20349 .environment_materialization_profiles
20350 .write()
20351 .unwrap_or_else(|poisoned| poisoned.into_inner())
20352 .insert(profile_id.clone(), materialization.unwrap());
20353 (
20354 shared,
20355 runtime,
20356 ResolvedEnvironmentProfileReceipt {
20357 profile_id,
20358 profile_revision,
20359 network_allowlist: None,
20360 browser_profile_id: None,
20361 },
20362 root,
20363 deny,
20364 )
20365 }
20366
20367 #[cfg(feature = "fixture")]
20368 #[derive(Clone, Copy)]
20369 enum CodexHomeProfile {
20370 Missing,
20371 WrongClass,
20372 Exact,
20373 }
20374
20375 #[cfg(feature = "fixture")]
20376 fn codex_bundle_test_shared(
20377 home_profile: CodexHomeProfile,
20378 ) -> (
20379 NodeShared,
20380 NativeRuntime,
20381 ResolvedEnvironmentProfileReceipt,
20382 NodeBundle,
20383 PathBuf,
20384 ) {
20385 const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20386 const CLAUDE_MANIFEST: &[u8] =
20387 br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20388 const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20389 const DIGEST: &str =
20390 "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20391
20392 let (shared, mut runtime, _claude_receipt, root, _deny) =
20393 materialization_test_shared(false);
20394 let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20395 CodexHomeProfile::Missing => "codex-home-missing",
20396 CodexHomeProfile::WrongClass => "codex-home-wrong",
20397 CodexHomeProfile::Exact => "codex-home-exact",
20398 })
20399 .unwrap();
20400 let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20401 "codex-home-r1",
20402 )
20403 .unwrap();
20404 let materialization = match home_profile {
20405 CodexHomeProfile::Missing => None,
20406 CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20407 Some(NodeSessionMaterializationProfile::new(
20408 Vec::new(),
20409 vec![NodeSessionPathBinding::new(
20410 "CODEX_HOME",
20411 if matches!(home_profile, CodexHomeProfile::Exact) {
20412 NodeSessionPathClass::ProviderHome
20413 } else {
20414 NodeSessionPathClass::Config
20415 },
20416 )
20417 .unwrap()],
20418 Vec::new(),
20419 )
20420 .unwrap())
20421 }
20422 };
20423 let node_profile = NodeEnvironmentProfile::new_with_materialization(
20424 profile_id.clone(),
20425 profile_revision.clone(),
20426 agent("codex"),
20427 [NativeLaunchProfile::new(
20428 NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20429 agent("codex"),
20430 TransportKind::Pty,
20431 vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20432 Arc::new(EmptyEnvironmentResolver),
20433 )
20434 .unwrap()],
20435 materialization,
20436 )
20437 .unwrap();
20438 let (binding, native_profiles, materialization) = node_profile.into_parts();
20439 for native_profile in native_profiles {
20440 runtime.upsert_native_launch_profile(native_profile).unwrap();
20441 }
20442 shared
20443 .environment_profiles
20444 .write()
20445 .unwrap_or_else(|poisoned| poisoned.into_inner())
20446 .insert(binding.id.clone(), binding);
20447 if let Some(materialization) = materialization {
20448 shared
20449 .environment_materialization_profiles
20450 .write()
20451 .unwrap_or_else(|poisoned| poisoned.into_inner())
20452 .insert(profile_id.clone(), materialization);
20453 }
20454
20455 let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20456 std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20457 std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20458 std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20459 std::fs::write(
20460 bundle_source.join(".claude-plugin/plugin.json"),
20461 CLAUDE_MANIFEST,
20462 )
20463 .unwrap();
20464 std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20465 crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20466 let bundle = NodeBundle::new(
20467 crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20468 crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20469 crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20470 &bundle_source,
20471 )
20472 .unwrap();
20473 *shared
20474 .bundle_catalog
20475 .write()
20476 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20477 BundleCatalog::new([bundle.clone()]).unwrap();
20478 (
20479 shared,
20480 runtime,
20481 ResolvedEnvironmentProfileReceipt {
20482 profile_id,
20483 profile_revision,
20484 network_allowlist: None,
20485 browser_profile_id: None,
20486 },
20487 bundle,
20488 root,
20489 )
20490 }
20491
20492 #[cfg(feature = "fixture")]
20493 #[test]
20494 fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20495 for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20496 let (shared, runtime, receipt, bundle, root) =
20497 codex_bundle_test_shared(home_profile);
20498 let error = shared
20499 .bundle_layout(
20500 &agent("codex"),
20501 SessionMode::Pty,
20502 Some(&receipt),
20503 &bundle,
20504 )
20505 .unwrap_err();
20506 assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20507 assert!(shared
20508 .materializations
20509 .lock()
20510 .unwrap_or_else(|poisoned| poisoned.into_inner())
20511 .is_empty());
20512 let materialization_root = root.join("session-environments");
20513 assert!(!std::fs::read_dir(&materialization_root)
20514 .unwrap()
20515 .filter_map(Result::ok)
20516 .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20517 drop(shared);
20518 drop(runtime);
20519 let _ = std::fs::remove_dir_all(root);
20520 }
20521 }
20522
20523 #[cfg(feature = "fixture")]
20524 #[test]
20525 fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20526 let (shared, runtime, receipt, bundle, root) =
20527 codex_bundle_test_shared(CodexHomeProfile::Exact);
20528 let address = SessionAddress {
20529 workspace_id: WorkspaceId::new("primary").unwrap(),
20530 session: SessionKey {
20531 instance_id: AgentInstanceId(310),
20532 generation: SessionGeneration(1),
20533 },
20534 };
20535 let (overlay, guard) = shared
20536 .prepare_session_materialization(
20537 &address,
20538 &agent("codex"),
20539 SessionMode::Pty,
20540 Some(&receipt),
20541 Some(&bundle.receipt()),
20542 None,
20543 None,
20544 )
20545 .unwrap()
20546 .unwrap();
20547 assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20548 let ownership = shared
20549 .materializations
20550 .lock()
20551 .unwrap_or_else(|poisoned| poisoned.into_inner())
20552 .get(guard.id().unwrap())
20553 .unwrap()
20554 .clone();
20555 let (profile, _, _) = shared
20556 .materialization_profile(
20557 &agent("codex"),
20558 SessionMode::Pty,
20559 Some(&receipt),
20560 Some(&bundle.receipt()),
20561 None,
20562 )
20563 .unwrap()
20564 .unwrap();
20565 let environment = shared
20566 .session_environment_materializer
20567 .as_ref()
20568 .unwrap()
20569 .resolve_environment(&ownership, &profile)
20570 .unwrap();
20571 assert!(environment.iter().any(|mutation| {
20572 mutation.key == OsString::from("CODEX_HOME")
20573 && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20574 }));
20575 assert_eq!(
20576 std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
20577 .unwrap(),
20578 b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
20579 );
20580 assert!(std::fs::read_dir(ownership.bundle_root())
20581 .unwrap()
20582 .next()
20583 .is_none());
20584 drop(guard);
20585 drop(shared);
20586 drop(runtime);
20587 let _ = std::fs::remove_dir_all(root);
20588 }
20589
20590 #[cfg(feature = "fixture")]
20591 #[test]
20592 fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
20593 let (shared, runtime, receipt, bundle, root) =
20594 codex_bundle_test_shared(CodexHomeProfile::Exact);
20595 let bundle_receipt = bundle.receipt();
20596 let address = SessionAddress {
20597 workspace_id: WorkspaceId::new("primary").unwrap(),
20598 session: SessionKey {
20599 instance_id: AgentInstanceId(311),
20600 generation: SessionGeneration(1),
20601 },
20602 };
20603 let (_overlay, guard) = shared
20604 .prepare_session_materialization(
20605 &address,
20606 &agent("codex"),
20607 SessionMode::Pty,
20608 Some(&receipt),
20609 Some(&bundle_receipt),
20610 None,
20611 None,
20612 )
20613 .unwrap()
20614 .unwrap();
20615 let materialization_id = guard.id().unwrap().clone();
20616 let skill_path = shared
20617 .materializations
20618 .lock()
20619 .unwrap_or_else(|poisoned| poisoned.into_inner())
20620 .get(&materialization_id)
20621 .unwrap()
20622 .provider_home()
20623 .join("skills/review-code/SKILL.md");
20624 let record_id = shared
20625 .bind_spawn_session_with_materialization(
20626 &address,
20627 agent("codex"),
20628 SessionMode::Pty,
20629 ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
20630 SpawnRecordPolicy::ProviderIdentityOnly,
20631 Some(receipt.clone()),
20632 Some(bundle_receipt.clone()),
20633 None,
20634 Some(materialization_id.clone()),
20635 )
20636 .unwrap()
20637 .unwrap();
20638 guard.retain();
20639 std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
20640
20641 let error = match shared.resolve_record_materialization(
20642 &record_id,
20643 &agent("codex"),
20644 SessionMode::Pty,
20645 Some(&receipt),
20646 Some(&bundle_receipt),
20647 None,
20648 ) {
20649 Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
20650 Err(error) => error,
20651 };
20652 assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
20653 assert_eq!(
20654 shared
20655 .materializations
20656 .lock()
20657 .unwrap_or_else(|poisoned| poisoned.into_inner())
20658 .get(&materialization_id)
20659 .unwrap()
20660 .state(),
20661 MaterializationState::RecoveryRequired,
20662 );
20663 drop(shared);
20664 drop(runtime);
20665 let _ = std::fs::remove_dir_all(root);
20666 }
20667
20668 fn bind_record_owned_materialization_fixture(
20669 shared: &NodeShared,
20670 receipt: &ResolvedEnvironmentProfileReceipt,
20671 instance_id: u64,
20672 ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
20673 let address = SessionAddress {
20674 workspace_id: WorkspaceId::new("primary").unwrap(),
20675 session: SessionKey {
20676 instance_id: AgentInstanceId(instance_id),
20677 generation: SessionGeneration(1),
20678 },
20679 };
20680 let (overlay, materialization_guard) = shared
20681 .prepare_session_materialization(
20682 &address,
20683 &agent("claude"),
20684 SessionMode::Pty,
20685 Some(receipt),
20686 None,
20687 None,
20688 None,
20689 )
20690 .unwrap()
20691 .unwrap();
20692 let materialization_id = materialization_guard.id().unwrap().clone();
20693 let materialization_root = shared
20694 .materializations
20695 .lock()
20696 .unwrap_or_else(|poisoned| poisoned.into_inner())
20697 .get(&materialization_id)
20698 .unwrap()
20699 .root()
20700 .to_path_buf();
20701 let selection = shared
20702 .select_environment_profile(
20703 address.session.instance_id,
20704 &agent("claude"),
20705 SessionMode::Pty,
20706 Some(receipt),
20707 )
20708 .unwrap()
20709 .unwrap();
20710 assert!(shared
20711 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
20712 .unwrap()
20713 .is_none());
20714 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
20715 let record_id = shared
20716 .bind_spawn_session_with_materialization(
20717 &address,
20718 agent("claude"),
20719 SessionMode::Pty,
20720 policy,
20721 SpawnRecordPolicy::ProviderIdentityOnly,
20722 Some(receipt.clone()),
20723 None,
20724 None,
20725 Some(materialization_id.clone()),
20726 )
20727 .unwrap()
20728 .unwrap();
20729 selection.retain();
20730 materialization_guard.retain();
20731 (address, record_id, materialization_id, materialization_root)
20732 }
20733
20734 #[test]
20735 fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
20736 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20737 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20738 capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
20739 }));
20740 let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
20741 assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
20742 let mut spec = spawn_spec_fixture();
20743 spec.expected_profile_revision =
20744 crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
20745 assert!(matches!(
20746 &spec.overrides.environment_profile_id,
20747 crate::protocol::SpawnOverride::Inherit,
20748 ));
20749 assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
20750 .requires_child_environment_profile_capability());
20751 assert!(request_requires_child_environment_profile(
20752 &shared,
20753 &NodeRequest::SpawnSpec { spec: spec.clone() },
20754 ));
20755
20756 let address = terminal_address(1);
20757 shared.bind_session_with_policy(
20758 &address,
20759 ProviderRuntimePolicy::raw_pty(),
20760 Some(environment_profile.clone()),
20761 );
20762 assert!(request_requires_child_environment_profile(
20763 &shared,
20764 &NodeRequest::Input {
20765 session: address.clone(),
20766 text: "x".to_owned(),
20767 },
20768 ));
20769
20770 let mut record = record("claude", "profile-record");
20771 record.active_session = Some(address.clone());
20772 record.environment_profile = Some(environment_profile.clone());
20773 let record_id = record.record_id.clone();
20774 shared.insert_record(record.clone()).unwrap();
20775 assert!(request_requires_child_environment_profile(
20776 &shared,
20777 &NodeRequest::ResumeSessionRecord {
20778 record_id,
20779 terminal_size: gate4agent_types::TerminalSize {
20780 rows: 24,
20781 columns: 80,
20782 },
20783 initial_prompt: None,
20784 },
20785 ));
20786
20787 let snapshot = snapshot_for_wire(
20788 &shared, false, true, true, true, false, false, false, false, true,
20789 );
20790 assert_eq!(snapshot.session_records.len(), 1);
20791 assert!(snapshot.session_records[0].environment_profile.is_none());
20792 let event = project_event_without_child_environment_profile(NodeEventEnvelope {
20793 sequence: 1,
20794 event: NodeEvent::SessionRecordUpserted { record },
20795 });
20796 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20797 panic!("record projection changed the event kind");
20798 };
20799 assert!(record.environment_profile.is_none());
20800
20801 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
20802 let mut reply = ResponseEnvelope {
20803 request_id: 1,
20804 result: Ok(NodeResponse::SpawnSpecAccepted {
20805 receipt: resolved.receipt_with_environment(
20806 shared.incarnation_id,
20807 address,
20808 Some(environment_profile),
20809 ),
20810 }),
20811 };
20812 project_response_without_child_environment_profile(&mut reply);
20813 assert_eq!(
20814 reply.result.unwrap_err().code,
20815 NodeFailureCode::UnsupportedCapability,
20816 );
20817 }
20818
20819 #[test]
20820 fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
20821 let (shared, _runtime, environment_profile) = environment_profile_test_shared();
20822 let snapshot = shared.snapshot();
20823 let profiles = snapshot
20824 .launch_inventory
20825 .as_ref()
20826 .and_then(|inventory| inventory.spawn_profiles.as_ref())
20827 .unwrap();
20828 assert_eq!(profiles.len(), 1);
20829 assert_eq!(profiles[0].environment_profile, Some(environment_profile));
20830 assert!(snapshot.requires_child_environment_profile_capability());
20831
20832 let mut legacy = snapshot.clone();
20833 clear_snapshot_child_environment_profiles(&mut legacy);
20834 assert!(legacy
20835 .launch_inventory
20836 .as_ref()
20837 .unwrap()
20838 .spawn_profiles
20839 .as_ref()
20840 .unwrap()[0]
20841 .environment_profile
20842 .is_none());
20843 assert!(!legacy.requires_child_environment_profile_capability());
20844
20845 let profile = shared.spawn_profiles.iter().next().unwrap().clone();
20846 let bindings = shared
20847 .environment_profiles
20848 .read()
20849 .unwrap_or_else(|poisoned| poisoned.into_inner())
20850 .clone();
20851 let mut provider_mismatch = bindings.clone();
20852 provider_mismatch
20853 .values_mut()
20854 .next()
20855 .unwrap()
20856 .provider = agent("codex");
20857 assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
20858 let mut mode_mismatch = profile;
20859 mode_mismatch.mode = SessionMode::Inline;
20860 assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
20861
20862 shared
20863 .environment_profiles
20864 .write()
20865 .unwrap_or_else(|poisoned| poisoned.into_inner())
20866 .clear();
20867 assert!(shared
20868 .snapshot()
20869 .launch_inventory
20870 .unwrap()
20871 .spawn_profiles
20872 .unwrap()
20873 .is_empty());
20874 }
20875
20876 #[test]
20877 fn session_bundle_capability_is_state_aware_and_legacy_safe() {
20878 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20879 capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
20880 }));
20881 let shared = terminal_test_shared();
20882 let bundle = ResolvedBundleReceipt {
20883 id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
20884 revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
20885 digest: crate::protocol::SpawnBundleDigest::new(format!(
20886 "sha256:{}",
20887 "0".repeat(64),
20888 ))
20889 .unwrap(),
20890 };
20891 let mut bundled = record("claude", "bundle-record");
20892 bundled.bundle = Some(bundle.clone());
20893 let record_id = bundled.record_id.clone();
20894 shared.insert_record(bundled.clone()).unwrap();
20895 assert!(request_requires_session_bundle(
20896 &shared,
20897 &NodeRequest::ForgetSessionRecord {
20898 record_id: record_id.clone(),
20899 },
20900 ));
20901
20902 let snapshot = snapshot_for_wire(
20903 &shared, false, true, true, true, true, false, false, false, true,
20904 );
20905 assert!(snapshot.session_records[0].bundle.is_none());
20906 let launch = snapshot.launch_inventory.as_ref().unwrap();
20907 assert!(launch.spawn_profiles.is_some());
20908 assert!(launch.bundles.is_none());
20909 let event = project_event_without_session_bundle(NodeEventEnvelope {
20910 sequence: 1,
20911 event: NodeEvent::SessionRecordUpserted { record: bundled },
20912 });
20913 let NodeEvent::SessionRecordUpserted { record } = event.event else {
20914 panic!("record projection changed the event kind");
20915 };
20916 assert!(record.bundle.is_none());
20917
20918 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
20919 let mut reply = ResponseEnvelope {
20920 request_id: 1,
20921 result: Ok(NodeResponse::SpawnSpecAccepted {
20922 receipt: resolved.receipt_with_materialization(
20923 shared.incarnation_id,
20924 terminal_address(17),
20925 None,
20926 Some(bundle),
20927 None,
20928 ),
20929 }),
20930 };
20931 project_response_without_session_bundle(&mut reply);
20932 assert_eq!(
20933 reply.result.unwrap_err().code,
20934 NodeFailureCode::UnsupportedCapability,
20935 );
20936 }
20937
20938 #[test]
20939 fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
20940 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20941 capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
20942 }));
20943 let context_id = SpawnContextId::new("context-review").unwrap();
20944 let context = ResolvedContextPackReceipt {
20945 id: context_id.clone(),
20946 digest: crate::protocol::SpawnContextDigest::new(format!(
20947 "sha256:{}",
20948 "c".repeat(64),
20949 ))
20950 .unwrap(),
20951 lineage: ContextPackLineageReceipt {
20952 source_node_id: NodeId::new("node-source").unwrap(),
20953 source_session: terminal_address(1),
20954 source_provider: agent("kimi"),
20955 },
20956 source_message_count: 2,
20957 retained_message_count: 2,
20958 byte_len: 256,
20959 truncated: false,
20960 };
20961 assert!(context.is_valid());
20962 let mut bound_record = record("codex", "context-record");
20963 bound_record.context_id = Some(context_id);
20964 bound_record.context = Some(context.clone());
20965 let session = gate4agent_types::SessionSnapshot {
20966 instance_id: AgentInstanceId(91),
20967 agent_id: agent("kimi"),
20968 transport: TransportKind::Pty,
20969 generation: SessionGeneration(3),
20970 status: SessionStatus::Running,
20971 pending_operation: None,
20972 pending_input: None,
20973 process_id: Some(9001),
20974 terminal_size: None,
20975 terminal_frame: None,
20976 terminal_stale: None,
20977 session_options: None,
20978 capabilities: gate4agent_types::CapabilitySnapshot::default(),
20979 history: gate4agent_types::HistorySnapshot {
20980 pending: None,
20981 candidates: vec![HistoryCandidateSummary {
20982 id: "candidate-opaque".to_owned(),
20983 session_id_hint: "session-hint".to_owned(),
20984 modified_at_unix_ms: Some(1),
20985 }],
20986 loaded_candidate_id: Some("candidate-opaque".to_owned()),
20987 loaded: Some(HistorySessionRecord {
20988 session_id: "private-vendor-session".to_owned(),
20989 title: Some("private title".to_owned()),
20990 cwd: Some(r"C:\private\history-root".to_owned()),
20991 model: Some("private-model".to_owned()),
20992 message_count: 2,
20993 completed_turn_count: None,
20994 total_tokens: 19,
20995 messages: vec![
20996 gate4agent_types::HistoryMessageRecord {
20997 role: gate4agent_types::HistoryMessageRole::User,
20998 text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
20999 },
21000 gate4agent_types::HistoryMessageRecord {
21001 role: gate4agent_types::HistoryMessageRole::Assistant,
21002 text: "private answer".to_owned(),
21003 },
21004 ],
21005 }),
21006 last_error: None,
21007 },
21008 resume: gate4agent_types::ResumeSnapshot::default(),
21009 foreground: gate4agent_types::ForegroundSnapshot::default(),
21010 provider: gate4agent_types::ProviderSnapshot::default(),
21011 screen_state: gate4agent_types::PtyScreenState::default(),
21012 };
21013 let original = NodeSnapshot {
21014 node_id: NodeId::new("node-terminal-test").unwrap(),
21015 enabled_providers: vec![agent("kimi"), agent("codex")],
21016 provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21017 workspaces: vec![WorkspaceSnapshot {
21018 workspace_id: WorkspaceId::new("primary").unwrap(),
21019 canonical_root: opaque_windows_path(
21020 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21021 ),
21022 sessions: vec![session],
21023 worktree_service_mode: None,
21024 managed_worktree_profiles: None,
21025 }],
21026 session_records: vec![bound_record],
21027 managed_worktrees: Vec::new(),
21028 launch_inventory: None,
21029 agent_progress: Vec::new(),
21030 };
21031
21032 let mut negotiated = original.clone();
21033 project_snapshot_history_for_wire(&mut negotiated, true);
21034 assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21035 assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21036 assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21037 assert!(!serde_json::to_string(&negotiated)
21038 .unwrap()
21039 .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21040
21041 let mut legacy = original;
21042 project_snapshot_history_for_wire(&mut legacy, false);
21043 clear_snapshot_context_packs(&mut legacy);
21044 assert_eq!(
21045 legacy.workspaces[0].sessions[0].history,
21046 gate4agent_types::HistorySnapshot::default(),
21047 );
21048 assert!(legacy.session_records[0].context_id.is_none());
21049 assert!(legacy.session_records[0].context.is_none());
21050 let encoded = serde_json::to_string(&legacy).unwrap();
21051 assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21052 assert!(!encoded.contains("context-review"));
21053
21054 let mut reply = ResponseEnvelope {
21055 request_id: 7,
21056 result: Ok(NodeResponse::ContextPackExported { context }),
21057 };
21058 project_response_without_context_pack(&mut reply);
21059 assert_eq!(
21060 reply.result.unwrap_err().code,
21061 NodeFailureCode::UnsupportedCapability,
21062 );
21063 }
21064
21065 #[test]
21066 fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21067 let shared = terminal_test_shared();
21068 let history = HistorySessionRecord {
21069 session_id: "fixture-history".to_owned(),
21070 title: None,
21071 cwd: None,
21072 model: None,
21073 message_count: 1,
21074 completed_turn_count: None,
21075 total_tokens: 0,
21076 messages: vec![gate4agent_types::HistoryMessageRecord {
21077 role: gate4agent_types::HistoryMessageRole::User,
21078 text: "bounded handoff".to_owned(),
21079 }],
21080 };
21081 let pack = NodeContextPack::export(
21082 ContextPackLineageReceipt {
21083 source_node_id: shared.node_id.clone(),
21084 source_session: terminal_address(1),
21085 source_provider: agent("claude"),
21086 },
21087 &history,
21088 )
21089 .unwrap();
21090 let context = shared
21091 .context_catalog
21092 .write()
21093 .unwrap_or_else(|poisoned| poisoned.into_inner())
21094 .insert(pack)
21095 .unwrap();
21096 let mut retained = record("codex", "context-retained-record");
21097 retained.context_id = Some(context.id.clone());
21098 retained.context = Some(context.clone());
21099 let record_id = retained.record_id.clone();
21100 shared.insert_record(retained).unwrap();
21101 assert_eq!(
21102 shared.forget_context_pack(&context.id).unwrap_err().code,
21103 NodeFailureCode::ContextPackBusy,
21104 );
21105 assert!(shared
21106 .context_catalog
21107 .read()
21108 .unwrap_or_else(|poisoned| poisoned.into_inner())
21109 .get(&context.id)
21110 .is_some());
21111 shared
21112 .session_records
21113 .lock()
21114 .unwrap_or_else(|poisoned| poisoned.into_inner())
21115 .records
21116 .remove(&record_id);
21117
21118 let mut spec = spawn_spec_fixture();
21119 spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21120 value: context.id.clone(),
21121 };
21122 let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21123 let receipt = resolved.receipt_with_materialization(
21124 shared.incarnation_id,
21125 terminal_address(2),
21126 None,
21127 None,
21128 Some(context.clone()),
21129 );
21130 assert!(receipt.context_binding_is_valid());
21131 shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21132
21133 shared.forget_context_pack(&context.id).unwrap();
21134 assert!(shared
21135 .context_catalog
21136 .read()
21137 .unwrap_or_else(|poisoned| poisoned.into_inner())
21138 .get(&context.id)
21139 .is_none());
21140 assert!(shared
21141 .replay_spawn_spec(&spec, Instant::now())
21142 .unwrap()
21143 .is_none());
21144 }
21145
21146 #[test]
21147 fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21148 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21149 let address = terminal_address(1);
21150 let selection = shared
21151 .select_environment_profile(
21152 address.session.instance_id,
21153 &agent("claude"),
21154 SessionMode::Pty,
21155 Some(&environment_profile),
21156 )
21157 .unwrap()
21158 .unwrap();
21159 shared.bind_session_with_policy(
21160 &address,
21161 ProviderRuntimePolicy::raw_pty(),
21162 Some(environment_profile.clone()),
21163 );
21164 selection.retain();
21165 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21166 assert!(matches!(
21167 runtime.remove_native_launch_profile(&native_id),
21168 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21169 ));
21170 assert!(shared.remove_binding(&address).is_some());
21171 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21172
21173 let mismatched = ResolvedEnvironmentProfileReceipt {
21174 profile_id: environment_profile.profile_id,
21175 profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21176 "local-claude-r2",
21177 )
21178 .unwrap(),
21179 network_allowlist: None,
21180 browser_profile_id: None,
21181 };
21182 let error = match shared.select_environment_profile(
21183 AgentInstanceId(42),
21184 &agent("claude"),
21185 SessionMode::Pty,
21186 Some(&mismatched),
21187 ) {
21188 Ok(_) => panic!("mismatched environment profile revision was selected"),
21189 Err(error) => error,
21190 };
21191 assert_eq!(
21192 error.code,
21193 NodeFailureCode::EnvironmentProfileBindingMismatch,
21194 );
21195 assert!(!shared
21196 .session_bindings
21197 .lock()
21198 .unwrap_or_else(|poisoned| poisoned.into_inner())
21199 .contains_key(&AgentInstanceId(42)));
21200 }
21201
21202 #[test]
21203 fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21204 let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21205 let address = terminal_address(1);
21206 let selection = shared
21207 .select_environment_profile(
21208 address.session.instance_id,
21209 &agent("claude"),
21210 SessionMode::Pty,
21211 Some(&environment_profile),
21212 )
21213 .unwrap()
21214 .unwrap();
21215 let overlay = NativeInstanceLaunchOverlay::new(
21216 agent("claude"),
21217 TransportKind::Pty,
21218 vec![EnvMutation {
21219 key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21220 value: Some(OsString::from("fixture-secret")),
21221 }],
21222 vec![
21223 OsString::from("--plugin-dir"),
21224 std::env::current_dir().unwrap().into_os_string(),
21225 ],
21226 )
21227 .unwrap();
21228 let instance_overlay = shared
21229 .install_prepared_launch_overlay(
21230 address.session.instance_id,
21231 PreparedNativeLaunchOverlay::Instance(overlay),
21232 )
21233 .unwrap()
21234 .unwrap();
21235 let bundle = ResolvedBundleReceipt {
21236 id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21237 revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21238 digest: crate::protocol::SpawnBundleDigest::new(format!(
21239 "sha256:{}",
21240 "0".repeat(64),
21241 ))
21242 .unwrap(),
21243 };
21244 shared.bind_session_with_materialization(
21245 &address,
21246 ProviderRuntimePolicy::raw_pty(),
21247 Some(environment_profile),
21248 Some(bundle),
21249 None,
21250 None,
21251 );
21252 selection.retain();
21253 instance_overlay.retain();
21254
21255 let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21256 assert!(matches!(
21257 runtime.remove_native_launch_profile(&native_id),
21258 Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21259 ));
21260 assert!(shared.remove_binding(&address).is_some());
21261 assert!(!shared
21262 .native_launch_profile_control
21263 .as_ref()
21264 .unwrap()
21265 .clear_native_instance_launch_overlay(address.session.instance_id));
21266 assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21267 }
21268
21269 #[test]
21270 fn session_environment_materialization_failure_is_pre_child_and_durable() {
21271 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21272 let address = SessionAddress {
21273 workspace_id: WorkspaceId::new("primary").unwrap(),
21274 session: SessionKey {
21275 instance_id: AgentInstanceId(201),
21276 generation: SessionGeneration(1),
21277 },
21278 };
21279 let error = match shared.prepare_session_materialization(
21280 &address,
21281 &agent("claude"),
21282 SessionMode::Pty,
21283 Some(&receipt),
21284 None,
21285 None,
21286 None,
21287 ) {
21288 Ok(_) => panic!("denied local reference unexpectedly materialized"),
21289 Err(error) => error,
21290 };
21291 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21292 assert!(shared.handle.snapshot().sessions.is_empty());
21293 assert!(shared
21294 .session_bindings
21295 .lock()
21296 .unwrap_or_else(|poisoned| poisoned.into_inner())
21297 .is_empty());
21298 assert!(shared
21299 .materializations
21300 .lock()
21301 .unwrap_or_else(|poisoned| poisoned.into_inner())
21302 .is_empty());
21303 drop(shared);
21304 drop(runtime);
21305 std::fs::remove_dir_all(root).unwrap();
21306 }
21307
21308 #[test]
21309 fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21310 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21311 let address = SessionAddress {
21312 workspace_id: WorkspaceId::new("primary").unwrap(),
21313 session: SessionKey {
21314 instance_id: AgentInstanceId(202),
21315 generation: SessionGeneration(1),
21316 },
21317 };
21318 let (overlay, materialization_guard) = shared
21319 .prepare_session_materialization(
21320 &address,
21321 &agent("claude"),
21322 SessionMode::Pty,
21323 Some(&receipt),
21324 None,
21325 None,
21326 None,
21327 )
21328 .unwrap()
21329 .unwrap();
21330 let materialization_id = materialization_guard.id().unwrap().clone();
21331 let materialization_root = shared
21332 .materializations
21333 .lock()
21334 .unwrap_or_else(|poisoned| poisoned.into_inner())
21335 .get(&materialization_id)
21336 .unwrap()
21337 .root()
21338 .to_path_buf();
21339 let selection = shared
21340 .select_environment_profile(
21341 address.session.instance_id,
21342 &agent("claude"),
21343 SessionMode::Pty,
21344 Some(&receipt),
21345 )
21346 .unwrap()
21347 .unwrap();
21348 assert!(shared
21349 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21350 .unwrap()
21351 .is_none());
21352 shared.bind_session_with_materialization(
21353 &address,
21354 ProviderRuntimePolicy::raw_pty(),
21355 Some(receipt),
21356 None,
21357 None,
21358 Some(materialization_id),
21359 );
21360 selection.retain();
21361 materialization_guard.retain();
21362
21363 assert!(materialization_root.is_dir());
21364 assert!(shared
21365 .session_bindings
21366 .lock()
21367 .unwrap_or_else(|poisoned| poisoned.into_inner())
21368 .contains_key(&address.session.instance_id));
21369
21370 let removed = shared.remove_binding(&address).unwrap();
21371 shared
21372 .cleanup_session_owned_materialization(&address, &removed)
21373 .unwrap();
21374 assert!(!materialization_root.exists());
21375 assert!(shared
21376 .materializations
21377 .lock()
21378 .unwrap_or_else(|poisoned| poisoned.into_inner())
21379 .is_empty());
21380 drop(shared);
21381 drop(runtime);
21382 std::fs::remove_dir_all(root).unwrap();
21383 }
21384
21385 #[tokio::test]
21386 async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21387 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21388 let address = SessionAddress {
21389 workspace_id: WorkspaceId::new("primary").unwrap(),
21390 session: SessionKey {
21391 instance_id: AgentInstanceId(203),
21392 generation: SessionGeneration(1),
21393 },
21394 };
21395 let (overlay, materialization_guard) = shared
21396 .prepare_session_materialization(
21397 &address,
21398 &agent("claude"),
21399 SessionMode::Pty,
21400 Some(&receipt),
21401 None,
21402 None,
21403 None,
21404 )
21405 .unwrap()
21406 .unwrap();
21407 let materialization_id = materialization_guard.id().unwrap().clone();
21408 let materialization_root = shared
21409 .materializations
21410 .lock()
21411 .unwrap_or_else(|poisoned| poisoned.into_inner())
21412 .get(&materialization_id)
21413 .unwrap()
21414 .root()
21415 .to_path_buf();
21416 let selection = shared
21417 .select_environment_profile(
21418 address.session.instance_id,
21419 &agent("claude"),
21420 SessionMode::Pty,
21421 Some(&receipt),
21422 )
21423 .unwrap()
21424 .unwrap();
21425 assert!(shared
21426 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21427 .unwrap()
21428 .is_none());
21429 let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21430 let record_id = shared
21431 .bind_spawn_session_with_materialization(
21432 &address,
21433 agent("claude"),
21434 SessionMode::Pty,
21435 policy,
21436 SpawnRecordPolicy::ProviderIdentityOnly,
21437 Some(receipt.clone()),
21438 None,
21439 None,
21440 Some(materialization_id.clone()),
21441 )
21442 .unwrap()
21443 .unwrap();
21444 selection.retain();
21445 materialization_guard.retain();
21446 assert_eq!(
21447 shared
21448 .materializations
21449 .lock()
21450 .unwrap_or_else(|poisoned| poisoned.into_inner())
21451 .get(&materialization_id)
21452 .unwrap()
21453 .owner(),
21454 &MaterializationOwner::Record {
21455 record_id: record_id.clone(),
21456 },
21457 );
21458
21459 let removed = shared.remove_binding(&address).unwrap();
21460 shared
21461 .cleanup_session_owned_materialization(&address, &removed)
21462 .unwrap();
21463 assert!(materialization_root.is_dir());
21464 assert!(shared
21465 .resolve_record_materialization(
21466 &record_id,
21467 &agent("claude"),
21468 SessionMode::Pty,
21469 Some(&receipt),
21470 None,
21471 None,
21472 )
21473 .unwrap()
21474 .is_some());
21475 {
21476 let mut records = shared
21477 .session_records
21478 .lock()
21479 .unwrap_or_else(|poisoned| poisoned.into_inner());
21480 let record = records.records.get_mut(&record_id).unwrap();
21481 record.active_session = None;
21482 record.state = ManagedSessionState::Unavailable;
21483 }
21484 shared.forget_session_record(&record_id).await.unwrap();
21485 assert!(!materialization_root.exists());
21486 assert!(shared
21487 .materializations
21488 .lock()
21489 .unwrap_or_else(|poisoned| poisoned.into_inner())
21490 .is_empty());
21491 drop(shared);
21492 drop(runtime);
21493 std::fs::remove_dir_all(root).unwrap();
21494 }
21495
21496 #[test]
21497 fn record_materialization_revalidation_failure_persists_recovery_required() {
21498 let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21499 let state_path = root.join("node-state-v6.json");
21500 shared.state_path = Some(state_path.clone());
21501 let (_address, _record_id, materialization_id, materialization_root) =
21502 bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21503 std::fs::write(
21504 materialization_root.join(".gate4agent-materialization-owner"),
21505 b"tampered",
21506 )
21507 .unwrap();
21508
21509 let error = match shared.resolve_record_materialization(
21510 &_record_id,
21511 &agent("claude"),
21512 SessionMode::Pty,
21513 Some(&receipt),
21514 None,
21515 None,
21516 ) {
21517 Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21518 Err(error) => error,
21519 };
21520 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21521 assert_eq!(
21522 shared
21523 .materializations
21524 .lock()
21525 .unwrap_or_else(|poisoned| poisoned.into_inner())
21526 .get(&materialization_id)
21527 .unwrap()
21528 .state(),
21529 MaterializationState::RecoveryRequired,
21530 );
21531 let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21532 assert_eq!(loaded.materializations.len(), 1);
21533 assert_eq!(
21534 loaded.materializations[0].state(),
21535 MaterializationState::RecoveryRequired,
21536 );
21537 drop(shared);
21538 drop(runtime);
21539 std::fs::remove_dir_all(root).unwrap();
21540 }
21541
21542 #[test]
21543 fn transient_record_materialization_resolution_failure_remains_ready() {
21544 let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21545 let (_address, record_id, materialization_id, _materialization_root) =
21546 bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21547 deny.store(true, Ordering::Release);
21548
21549 let error = match shared.resolve_record_materialization(
21550 &record_id,
21551 &agent("claude"),
21552 SessionMode::Pty,
21553 Some(&receipt),
21554 None,
21555 None,
21556 ) {
21557 Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21558 Err(error) => error,
21559 };
21560 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21561 assert_eq!(
21562 shared
21563 .materializations
21564 .lock()
21565 .unwrap_or_else(|poisoned| poisoned.into_inner())
21566 .get(&materialization_id)
21567 .unwrap()
21568 .state(),
21569 MaterializationState::Ready,
21570 );
21571 drop(shared);
21572 drop(runtime);
21573 std::fs::remove_dir_all(root).unwrap();
21574 }
21575
21576 #[test]
21577 fn startup_missing_record_materialization_is_not_resumable() {
21578 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21579 let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
21580 shared
21581 .insert_record(ManagedSessionRecord {
21582 record_id: record_id.clone(),
21583 display_name: "missing materialization".to_owned(),
21584 provider: agent("claude"),
21585 mode: SessionMode::Pty,
21586 state: ManagedSessionState::Dormant,
21587 workspace_id: WorkspaceId::new("primary").unwrap(),
21588 canonical_root: opaque_windows_path(
21589 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21590 ),
21591 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
21592 key: gate4agent_types::ProviderSessionKey::SessionId,
21593 id: "provider-session-missing-materialization".to_owned(),
21594 transcript_path: None,
21595 }),
21596 active_session: None,
21597 environment_profile: Some(receipt),
21598 bundle: None,
21599 context_id: None,
21600 context: None,
21601 exported_context: None,
21602 task_binding: None,
21603 created_at_unix_ms: 1,
21604 updated_at_unix_ms: 1,
21605 last_error: None,
21606 })
21607 .unwrap();
21608
21609 shared.reconcile_materializations();
21610 let record = shared.record(&record_id).unwrap();
21611 assert_eq!(record.state, ManagedSessionState::Unavailable);
21612 assert!(record.provider_session.is_none());
21613 assert_eq!(
21614 record.last_error.as_deref(),
21615 Some("environment-profile-unavailable"),
21616 );
21617 drop(shared);
21618 drop(runtime);
21619 std::fs::remove_dir_all(root).unwrap();
21620 }
21621
21622 #[tokio::test]
21623 async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
21624 let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21625 let lease = install_managed_lease(&shared);
21626 let address = SessionAddress {
21627 workspace_id: WorkspaceId::new("primary").unwrap(),
21628 session: SessionKey {
21629 instance_id: AgentInstanceId(204),
21630 generation: SessionGeneration(1),
21631 },
21632 };
21633 let (overlay, materialization_guard) = shared
21634 .prepare_session_materialization(
21635 &address,
21636 &agent("claude"),
21637 SessionMode::Pty,
21638 Some(&receipt),
21639 None,
21640 None,
21641 Some(lease.lease_id.clone()),
21642 )
21643 .unwrap()
21644 .unwrap();
21645 let materialization_id = materialization_guard.id().unwrap().clone();
21646 let materialization_root = shared
21647 .materializations
21648 .lock()
21649 .unwrap_or_else(|poisoned| poisoned.into_inner())
21650 .get(&materialization_id)
21651 .unwrap()
21652 .root()
21653 .to_path_buf();
21654 let selection = shared
21655 .select_environment_profile(
21656 address.session.instance_id,
21657 &agent("claude"),
21658 SessionMode::Pty,
21659 Some(&receipt),
21660 )
21661 .unwrap()
21662 .unwrap();
21663 assert!(shared
21664 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21665 .unwrap()
21666 .is_none());
21667 shared.bind_session_with_materialization(
21668 &address,
21669 ProviderRuntimePolicy::raw_pty(),
21670 Some(receipt),
21671 None,
21672 None,
21673 Some(materialization_id),
21674 );
21675 selection.retain();
21676 materialization_guard.retain();
21677
21678 let blocked = shared
21679 .cleanup_managed_worktree(&lease.lease_id, false)
21680 .await
21681 .unwrap_err();
21682 assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
21683 assert!(materialization_root.is_dir());
21684 let removed = shared.remove_binding(&address).unwrap();
21685 shared
21686 .cleanup_session_owned_materialization(&address, &removed)
21687 .unwrap();
21688 assert!(!materialization_root.exists());
21689 drop(shared);
21690 drop(runtime);
21691 std::fs::remove_dir_all(root).unwrap();
21692 }
21693
21694 #[tokio::test]
21695 async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
21696 let git_root = temporary_workspace_root("raw-managed-inventory");
21697 let source = git_root.join("source");
21698 let allocation = git_root.join("allocation");
21699 std::fs::create_dir_all(&source).unwrap();
21700 std::fs::create_dir_all(&allocation).unwrap();
21701 run_test_git(&source, &["init"]);
21702 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
21703 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
21704 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
21705 run_test_git(&source, &["add", "seed.txt"]);
21706 run_test_git(&source, &["commit", "-m", "seed"]);
21707 let profile = ManagedWorktreeProfile::new(
21708 WorktreeProfileId::new("default").unwrap(),
21709 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21710 &allocation,
21711 "gate4agent",
21712 "HEAD",
21713 ManagedWorktreeRetention::RemoveWhenReleased,
21714 )
21715 .unwrap();
21716 let workspace = WorkspaceConfig::new(
21717 WorkspaceId::new("primary").unwrap(),
21718 &source,
21719 )
21720 .unwrap()
21721 .with_worktree_service_mode(WorktreeServiceMode::Managed)
21722 .with_managed_worktree_profile(profile.clone())
21723 .unwrap();
21724 let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
21725 materialization_test_shared_with_workspace(false, workspace);
21726 let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
21727 let source_text = source.to_string_lossy().into_owned();
21728 let target_text = target.to_string_lossy().into_owned();
21729 let created = create_git_worktree(
21730 &source_text,
21731 &target_text,
21732 "gate4agent/mw-raw-inventory",
21733 Some("HEAD"),
21734 )
21735 .await
21736 .unwrap();
21737 let lease = ManagedWorktreeLeaseRecord {
21738 lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
21739 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21740 workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
21741 profile_id: profile.profile_id().clone(),
21742 profile_revision: profile.revision().clone(),
21743 target_root: created.path.clone(),
21744 branch: "gate4agent/mw-raw-inventory".to_owned(),
21745 base_commit: created.head.clone(),
21746 expected_head: Some(created.head.clone()),
21747 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21748 state: ManagedWorktreeLeaseState::Ready,
21749 session_holders: Vec::new(),
21750 record_holders: Vec::new(),
21751 cleanup_failure: None,
21752 created_at_unix_ms: 1,
21753 updated_at_unix_ms: 1,
21754 };
21755 shared
21756 .workspaces
21757 .write()
21758 .unwrap_or_else(|poisoned| poisoned.into_inner())
21759 .insert(lease.workspace_id.clone(), created.path.clone());
21760 *shared
21761 .managed_worktrees
21762 .lock()
21763 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21764 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21765 let address = SessionAddress {
21766 workspace_id: lease.workspace_id.clone(),
21767 session: SessionKey {
21768 instance_id: AgentInstanceId(205),
21769 generation: SessionGeneration(1),
21770 },
21771 };
21772 let (overlay, materialization_guard) = shared
21773 .prepare_session_materialization(
21774 &address,
21775 &agent("claude"),
21776 SessionMode::Pty,
21777 Some(&receipt),
21778 None,
21779 None,
21780 Some(lease.lease_id.clone()),
21781 )
21782 .unwrap()
21783 .unwrap();
21784 let materialization_id = materialization_guard.id().unwrap().clone();
21785 let materialization_root = shared
21786 .materializations
21787 .lock()
21788 .unwrap_or_else(|poisoned| poisoned.into_inner())
21789 .get(&materialization_id)
21790 .unwrap()
21791 .root()
21792 .to_path_buf();
21793 let selection = shared
21794 .select_environment_profile(
21795 address.session.instance_id,
21796 &agent("claude"),
21797 SessionMode::Pty,
21798 Some(&receipt),
21799 )
21800 .unwrap()
21801 .unwrap();
21802 assert!(shared
21803 .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21804 .unwrap()
21805 .is_none());
21806 let record_id = shared
21807 .bind_spawn_session_with_materialization(
21808 &address,
21809 agent("claude"),
21810 SessionMode::Pty,
21811 ProviderRuntimePolicy::raw_pty(),
21812 SpawnRecordPolicy::Always,
21813 Some(receipt.clone()),
21814 None,
21815 None,
21816 Some(materialization_id.clone()),
21817 )
21818 .unwrap()
21819 .unwrap();
21820 selection.retain();
21821 materialization_guard.retain();
21822 let record = shared.record(&record_id).unwrap();
21823 assert_eq!(record.state, ManagedSessionState::Live);
21824 assert!(record.provider_session.is_none());
21825 assert!(record.environment_profile.is_none());
21826 assert!(record.bundle.is_none());
21827 assert!(record.context_id.is_none());
21828 assert!(record.context.is_none());
21829 assert_eq!(record.active_session.as_ref(), Some(&address));
21830 assert_eq!(
21831 shared
21832 .materializations
21833 .lock()
21834 .unwrap_or_else(|poisoned| poisoned.into_inner())
21835 .get(&materialization_id)
21836 .unwrap()
21837 .owner(),
21838 &MaterializationOwner::Session {
21839 incarnation_id: shared.incarnation_id,
21840 instance_id: address.session.instance_id,
21841 generation: address.session.generation,
21842 },
21843 );
21844 {
21845 let mut bindings = shared
21846 .session_bindings
21847 .lock()
21848 .unwrap_or_else(|poisoned| poisoned.into_inner());
21849 bindings
21850 .get_mut(&address.session.instance_id)
21851 .unwrap()
21852 .managed_worktree_lease_id = Some(lease.lease_id.clone());
21853 }
21854 let lease_snapshot = shared
21855 .managed_worktrees
21856 .lock()
21857 .unwrap_or_else(|poisoned| poisoned.into_inner())
21858 .bind_session(
21859 &lease.lease_id,
21860 ManagedWorktreeSessionHolder {
21861 incarnation_id: shared.incarnation_id,
21862 instance_id: address.session.instance_id,
21863 generation: address.session.generation,
21864 },
21865 None,
21866 unix_time_ms(),
21867 )
21868 .unwrap();
21869 assert_eq!(lease_snapshot.active_session_count, 1);
21870 assert_eq!(lease_snapshot.managed_record_count, 0);
21871 shared.persist_state().unwrap();
21872 assert!(shared
21873 .managed_worktrees
21874 .lock()
21875 .unwrap_or_else(|poisoned| poisoned.into_inner())
21876 .get(&lease.lease_id)
21877 .unwrap()
21878 .record_holders
21879 .is_empty());
21880 shared
21881 .handle
21882 .dispatch(shared.prepare_command(ControlCommand::Register {
21883 instance_id: address.session.instance_id,
21884 agent_id: agent("claude"),
21885 transport: TransportKind::Pty,
21886 }))
21887 .unwrap();
21888 runtime.tick().await;
21889 shared.reconcile_managed_record(
21890 &record_id,
21891 &address,
21892 &ControlEvent {
21893 sequence: 1,
21894 command_id: None,
21895 instance_id: address.session.instance_id,
21896 generation: address.session.generation,
21897 event: ControlEventKind::Removed,
21898 },
21899 false,
21900 false,
21901 );
21902 let remove = shared.remove_session(&address);
21903 let drive_runtime = async {
21904 runtime.tick().await;
21905 };
21906 let (removed, ()) = tokio::join!(remove, drive_runtime);
21907 removed.unwrap();
21908
21909 assert!(!materialization_root.exists());
21910 assert!(!Path::new(&created.path).exists());
21911 assert_eq!(
21912 shared
21913 .managed_worktrees
21914 .lock()
21915 .unwrap_or_else(|poisoned| poisoned.into_inner())
21916 .get(&lease.lease_id)
21917 .unwrap()
21918 .state,
21919 ManagedWorktreeLeaseState::Removed,
21920 );
21921 let renamed = shared
21922 .rename_session_record(&record_id, "raw managed inventory".to_owned())
21923 .unwrap();
21924 assert_eq!(renamed.record_id, record_id);
21925 assert_eq!(renamed.display_name, "raw managed inventory");
21926 assert_eq!(renamed.state, ManagedSessionState::Unavailable);
21927 assert!(renamed.active_session.is_none());
21928
21929 drop(shared);
21930 drop(runtime);
21931 std::fs::remove_dir_all(materialization_fixture_root).unwrap();
21932 std::fs::remove_dir_all(git_root).unwrap();
21933 }
21934
21935 fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
21936 let root = std::env::current_dir().unwrap()
21937 .join("managed-test-target")
21938 .to_string_lossy()
21939 .into_owned();
21940 let lease = ManagedWorktreeLeaseRecord {
21941 lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
21942 source_workspace_id: WorkspaceId::new("primary").unwrap(),
21943 workspace_id: WorkspaceId::new("managed-test").unwrap(),
21944 profile_id: WorktreeProfileId::new("default").unwrap(),
21945 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
21946 target_root: root.clone(),
21947 branch: "gate4agent/mw-test".to_owned(),
21948 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
21949 expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
21950 retention: ManagedWorktreeRetention::RemoveWhenReleased,
21951 state: ManagedWorktreeLeaseState::Ready,
21952 session_holders: Vec::new(),
21953 record_holders: Vec::new(),
21954 cleanup_failure: None,
21955 created_at_unix_ms: 1,
21956 updated_at_unix_ms: 1,
21957 };
21958 shared.workspaces.write()
21959 .unwrap_or_else(|poisoned| poisoned.into_inner())
21960 .insert(lease.workspace_id.clone(), root);
21961 *shared.managed_worktrees.lock()
21962 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
21963 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
21964 lease
21965 }
21966
21967 #[cfg(feature = "fixture")]
21968 #[test]
21969 fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
21970 let mut shared = terminal_test_shared();
21971 shared.fixture_semantic_hook_policy = true;
21972 let lease = install_managed_lease(&shared);
21973 let address = SessionAddress {
21974 workspace_id: lease.workspace_id.clone(),
21975 session: SessionKey {
21976 instance_id: AgentInstanceId(206),
21977 generation: SessionGeneration(1),
21978 },
21979 };
21980 let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
21981 let effective_runtime_policy =
21982 shared.effective_spawn_runtime_policy(pre_admission_policy);
21983 assert_ne!(effective_runtime_policy, pre_admission_policy);
21984 assert!(effective_runtime_policy.provider_session_identity);
21985
21986 let record_id = shared
21987 .bind_spawn_session(
21988 &address,
21989 agent("claude"),
21990 SessionMode::Pty,
21991 effective_runtime_policy,
21992 SpawnRecordPolicy::Always,
21993 None,
21994 )
21995 .unwrap()
21996 .unwrap();
21997 assert_eq!(
21998 shared.record(&record_id).unwrap().state,
21999 ManagedSessionState::IdentityPending,
22000 );
22001 let (record_holder, raw_inventory_record_id) = {
22002 let mut bindings = shared
22003 .session_bindings
22004 .lock()
22005 .unwrap_or_else(|poisoned| poisoned.into_inner());
22006 let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22007 binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22008 managed_spawn_record_ownership(binding, effective_runtime_policy)
22009 };
22010 assert_eq!(record_holder.as_ref(), Some(&record_id));
22011 assert!(raw_inventory_record_id.is_none());
22012 let snapshot = shared
22013 .managed_worktrees
22014 .lock()
22015 .unwrap_or_else(|poisoned| poisoned.into_inner())
22016 .bind_session(
22017 &lease.lease_id,
22018 ManagedWorktreeSessionHolder {
22019 incarnation_id: shared.incarnation_id,
22020 instance_id: address.session.instance_id,
22021 generation: address.session.generation,
22022 },
22023 record_holder,
22024 unix_time_ms(),
22025 )
22026 .unwrap();
22027 assert_eq!(snapshot.managed_record_count, 1);
22028 assert_eq!(
22029 shared
22030 .managed_worktrees
22031 .lock()
22032 .unwrap_or_else(|poisoned| poisoned.into_inner())
22033 .get(&lease.lease_id)
22034 .unwrap()
22035 .record_holders,
22036 vec![record_id.clone()],
22037 );
22038
22039 let identity = ProviderSessionIdentity {
22040 key: ProviderSessionKey::SessionId,
22041 id: "monitoring-fixture-managed-session".to_owned(),
22042 transcript_path: None,
22043 };
22044 shared.publish_control(ControlEvent {
22045 sequence: 1,
22046 command_id: None,
22047 instance_id: address.session.instance_id,
22048 generation: address.session.generation,
22049 event: ControlEventKind::ProviderEvent {
22050 sequence: 1,
22051 source: gate4agent_types::ProviderSource {
22052 family: AdapterFamily::Hook,
22053 binding: AdapterBinding::new(
22054 gate4agent_types::AdapterId::new("claude-code").unwrap(),
22055 "fixture/v1",
22056 gate4agent_types::AdapterVerification::SyntheticFixture,
22057 )
22058 .unwrap(),
22059 },
22060 source_sequence: 1,
22061 event: ProviderEvent::SessionIdentityObserved {
22062 identity: identity.clone(),
22063 },
22064 },
22065 });
22066
22067 let live = shared.record(&record_id).unwrap();
22068 assert_eq!(live.state, ManagedSessionState::Live);
22069 assert_eq!(live.provider_session.as_ref(), Some(&identity));
22070 assert_eq!(live.active_session.as_ref(), Some(&address));
22071 assert_eq!(
22072 shared
22073 .managed_worktrees
22074 .lock()
22075 .unwrap_or_else(|poisoned| poisoned.into_inner())
22076 .get(&lease.lease_id)
22077 .unwrap()
22078 .record_holders,
22079 vec![record_id],
22080 );
22081 }
22082
22083 fn managed_v2_test_shared(
22084 allocation_root: &Path,
22085 state_path: Option<PathBuf>,
22086 profile_revision: &str,
22087 ) -> NodeShared {
22088 let profile = ManagedWorktreeProfile::new(
22089 WorktreeProfileId::new("default").unwrap(),
22090 WorktreeProfileRevision::new(profile_revision).unwrap(),
22091 allocation_root,
22092 "gate4agent",
22093 "HEAD",
22094 ManagedWorktreeRetention::RemoveWhenReleased,
22095 )
22096 .unwrap();
22097 let workspace = WorkspaceConfig::new(
22098 WorkspaceId::new("primary").unwrap(),
22099 std::env::current_dir().unwrap(),
22100 )
22101 .unwrap()
22102 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22103 .with_managed_worktree_profile(profile)
22104 .unwrap();
22105 let catalog = active_registry().unwrap();
22106 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22107 let mut shared = NodeShared::new(
22108 handle,
22109 "fixture-token".to_owned(),
22110 NodeId::new("node-terminal-test").unwrap(),
22111 vec![workspace],
22112 vec![agent("claude")],
22113 );
22114 shared.state_path = state_path;
22115 shared
22116 }
22117
22118 fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22119 let mut spawn_spec = spawn_spec_fixture();
22120 spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22121 ManagedWorktreeSpawnRequestV2 {
22122 spawn_spec,
22123 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22124 expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22125 }
22126 }
22127
22128 #[test]
22129 fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22130 let shared = terminal_test_shared();
22131 let mut lease = install_managed_lease(&shared);
22132 let session = SessionAddress {
22133 workspace_id: lease.workspace_id.clone(),
22134 session: SessionKey {
22135 instance_id: AgentInstanceId(73),
22136 generation: SessionGeneration(4),
22137 },
22138 };
22139 lease.state = ManagedWorktreeLeaseState::InUse;
22140 lease.session_holders.push(ManagedWorktreeSessionHolder {
22141 incarnation_id: shared.incarnation_id,
22142 instance_id: session.session.instance_id,
22143 generation: session.session.generation,
22144 });
22145 let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22146 assert!(resolved.target.worktree_id.is_none());
22147
22148 let receipt = managed_spawn_receipt(
22149 &resolved,
22150 shared.incarnation_id,
22151 session.clone(),
22152 lease.snapshot(),
22153 None,
22154 None,
22155 None,
22156 );
22157 assert!(resolved.target.worktree_id.is_none());
22158 assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22159 assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22160 assert_eq!(receipt.spawn.session, session);
22161 assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22162 let encoded = serde_json::to_vec(&receipt).unwrap();
22163 let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22164 assert_eq!(decoded, receipt);
22165 }
22166
22167 #[tokio::test]
22168 async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22169 let root = temporary_workspace_root("managed-v2-revision");
22170 let allocation = root.join("allocation");
22171 std::fs::create_dir_all(&allocation).unwrap();
22172 let shared = managed_v2_test_shared(
22173 &allocation,
22174 Some(root.join("node-state.json")),
22175 "v1",
22176 );
22177 let mut request = managed_v2_request("managed-v2-revision");
22178 request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22179
22180 let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22181
22182 assert_eq!(
22183 failure.code,
22184 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22185 );
22186 assert!(shared
22187 .managed_worktrees
22188 .lock()
22189 .unwrap_or_else(|poisoned| poisoned.into_inner())
22190 .records()
22191 .is_empty());
22192 assert!(shared
22193 .managed_spawn_replays
22194 .lock()
22195 .unwrap_or_else(|poisoned| poisoned.into_inner())
22196 .is_empty());
22197 std::fs::remove_dir_all(root).unwrap();
22198 }
22199
22200 #[test]
22201 fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22202 let root = temporary_workspace_root("managed-v2-concurrent");
22203 let allocation = root.join("allocation");
22204 std::fs::create_dir_all(&allocation).unwrap();
22205 let shared = Arc::new(managed_v2_test_shared(
22206 &allocation,
22207 Some(root.join("node-state.json")),
22208 "v1",
22209 ));
22210 let request = managed_v2_request("managed-v2-concurrent");
22211 let barrier = Arc::new(std::sync::Barrier::new(3));
22212 let mut joins = Vec::new();
22213 for _ in 0..2 {
22214 let shared = Arc::clone(&shared);
22215 let request = request.clone();
22216 let barrier = Arc::clone(&barrier);
22217 joins.push(std::thread::spawn(move || {
22218 let digest = managed_spawn_request_digest_v2(&request).unwrap();
22219 barrier.wait();
22220 shared.acquire_durable_managed_spawn_v2(&request, digest)
22221 }));
22222 }
22223 barrier.wait();
22224 let results = joins
22225 .into_iter()
22226 .map(|join| join.join().unwrap())
22227 .collect::<Vec<_>>();
22228 assert_eq!(
22229 results
22230 .iter()
22231 .filter(|result| {
22232 matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22233 })
22234 .count(),
22235 1,
22236 );
22237 assert_eq!(
22238 results
22239 .iter()
22240 .filter(|result| {
22241 matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22242 })
22243 .count(),
22244 1,
22245 );
22246 assert_eq!(
22247 shared
22248 .managed_spawn_replays
22249 .lock()
22250 .unwrap_or_else(|poisoned| poisoned.into_inner())
22251 .len(),
22252 1,
22253 );
22254 assert!(shared
22255 .managed_worktrees
22256 .lock()
22257 .unwrap_or_else(|poisoned| poisoned.into_inner())
22258 .records()
22259 .is_empty());
22260 drop(shared);
22261 std::fs::remove_dir_all(root).unwrap();
22262 }
22263
22264 #[tokio::test]
22265 async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22266 let root = temporary_workspace_root("managed-v2-linked-pending");
22267 let allocation = root.join("allocation");
22268 std::fs::create_dir_all(&allocation).unwrap();
22269 let state_path = root.join("node-state.json");
22270 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22271 let lease = install_managed_lease(&shared);
22272 let request = managed_v2_request("managed-v2-linked-pending");
22273 assert!(matches!(
22274 shared
22275 .acquire_durable_managed_spawn_v2(
22276 &request,
22277 managed_spawn_request_digest_v2(&request).unwrap(),
22278 )
22279 .unwrap(),
22280 DurableManagedSpawnReplayDecision::Reserved,
22281 ));
22282 {
22283 let _transaction = shared
22284 .state_transaction
22285 .lock()
22286 .unwrap_or_else(|poisoned| poisoned.into_inner());
22287 shared
22288 .link_durable_managed_spawn_v2_lease_locked(
22289 &request.spawn_spec.idempotency_key,
22290 &lease.lease_id,
22291 )
22292 .unwrap();
22293 shared.persist_state_locked().unwrap();
22294 }
22295 drop(shared);
22296
22297 let mut loaded = session_registry::load(
22298 Some(&state_path),
22299 &NodeId::new("node-terminal-test").unwrap(),
22300 )
22301 .unwrap();
22302 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22303 *reopened
22304 .managed_worktrees
22305 .lock()
22306 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22307 ManagedWorktreeRegistry::from_records(
22308 std::mem::take(&mut loaded.managed_worktrees),
22309 std::mem::take(&mut loaded.managed_worktree_tombstones),
22310 )
22311 .unwrap();
22312 *reopened
22313 .managed_spawn_replays
22314 .lock()
22315 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22316 .managed_spawn_replays
22317 .into_iter()
22318 .map(|record| (record.idempotency_key.clone(), record))
22319 .collect();
22320 let lease_count = reopened
22321 .managed_worktrees
22322 .lock()
22323 .unwrap_or_else(|poisoned| poisoned.into_inner())
22324 .records()
22325 .len();
22326
22327 let failure = reopened
22328 .spawn_managed_worktree_v2(request)
22329 .await
22330 .unwrap_err();
22331
22332 assert_eq!(
22333 failure.code,
22334 NodeFailureCode::ManagedWorktreeRecoveryRequired,
22335 );
22336 assert_eq!(
22337 reopened
22338 .managed_worktrees
22339 .lock()
22340 .unwrap_or_else(|poisoned| poisoned.into_inner())
22341 .records()
22342 .len(),
22343 lease_count,
22344 );
22345 std::fs::remove_dir_all(root).unwrap();
22346 }
22347
22348 #[tokio::test]
22349 async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22350 let root = temporary_workspace_root("managed-v2-reopen");
22351 let allocation = root.join("allocation");
22352 std::fs::create_dir_all(&allocation).unwrap();
22353 let state_path = root.join("node-state.json");
22354 let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22355 let lease = install_managed_lease(&shared);
22356 let request = managed_v2_request("managed-v2-reopen");
22357 let session = SessionAddress {
22358 workspace_id: lease.workspace_id.clone(),
22359 session: SessionKey {
22360 instance_id: AgentInstanceId(91),
22361 generation: SessionGeneration(1),
22362 },
22363 };
22364 let snapshot = {
22365 let mut registry = shared
22366 .managed_worktrees
22367 .lock()
22368 .unwrap_or_else(|poisoned| poisoned.into_inner());
22369 let current = registry.get_mut(&lease.lease_id).unwrap();
22370 current.state = ManagedWorktreeLeaseState::InUse;
22371 current.session_holders.push(ManagedWorktreeSessionHolder {
22372 incarnation_id: shared.incarnation_id,
22373 instance_id: session.session.instance_id,
22374 generation: session.session.generation,
22375 });
22376 current.snapshot()
22377 };
22378 let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22379 let receipt = managed_spawn_receipt(
22380 &resolved,
22381 shared.incarnation_id,
22382 session,
22383 snapshot,
22384 None,
22385 None,
22386 None,
22387 );
22388 assert!(matches!(
22389 shared
22390 .acquire_durable_managed_spawn_v2(
22391 &request,
22392 managed_spawn_request_digest_v2(&request).unwrap(),
22393 )
22394 .unwrap(),
22395 DurableManagedSpawnReplayDecision::Reserved,
22396 ));
22397 {
22398 let _transaction = shared
22399 .state_transaction
22400 .lock()
22401 .unwrap_or_else(|poisoned| poisoned.into_inner());
22402 shared
22403 .link_durable_managed_spawn_v2_lease_locked(
22404 &request.spawn_spec.idempotency_key,
22405 &lease.lease_id,
22406 )
22407 .unwrap();
22408 shared.persist_state_locked().unwrap();
22409 }
22410 shared
22411 .commit_durable_managed_spawn_v2(&request, receipt.clone())
22412 .unwrap();
22413 let legacy_same_key = ManagedWorktreeSpawnRequest {
22414 spawn_spec: request.spawn_spec.clone(),
22415 worktree_profile_id: request.worktree_profile_id.clone(),
22416 };
22417 shared.remember_managed_spawn_attempt(
22418 Some(&request.spawn_spec.idempotency_key),
22419 legacy_same_key.clone(),
22420 Ok(receipt.clone()),
22421 Instant::now(),
22422 );
22423 assert!(shared
22424 .replay_managed_spawn(&legacy_same_key, Instant::now())
22425 .unwrap()
22426 .is_none());
22427 drop(shared);
22428
22429 let mut loaded = session_registry::load(
22430 Some(&state_path),
22431 &NodeId::new("node-terminal-test").unwrap(),
22432 )
22433 .unwrap();
22434 assert_eq!(loaded.managed_spawn_replays.len(), 1);
22435 let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22436 *reopened
22437 .managed_worktrees
22438 .lock()
22439 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22440 ManagedWorktreeRegistry::from_records(
22441 std::mem::take(&mut loaded.managed_worktrees),
22442 std::mem::take(&mut loaded.managed_worktree_tombstones),
22443 )
22444 .unwrap();
22445 *reopened
22446 .managed_spawn_replays
22447 .lock()
22448 .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22449 .managed_spawn_replays
22450 .into_iter()
22451 .map(|record| (record.idempotency_key.clone(), record))
22452 .collect();
22453 let lease_count_before = reopened
22454 .managed_worktrees
22455 .lock()
22456 .unwrap_or_else(|poisoned| poisoned.into_inner())
22457 .records()
22458 .len();
22459
22460 assert_eq!(
22461 reopened
22462 .spawn_managed_worktree_v2(request.clone())
22463 .await
22464 .unwrap(),
22465 receipt,
22466 );
22467 assert_eq!(
22468 reopened
22469 .managed_worktrees
22470 .lock()
22471 .unwrap_or_else(|poisoned| poisoned.into_inner())
22472 .records()
22473 .len(),
22474 lease_count_before,
22475 );
22476 let mut changed = request;
22477 changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22478 let failure = reopened
22479 .spawn_managed_worktree_v2(changed)
22480 .await
22481 .unwrap_err();
22482 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22483 assert_eq!(
22484 reopened
22485 .managed_worktrees
22486 .lock()
22487 .unwrap_or_else(|poisoned| poisoned.into_inner())
22488 .records()
22489 .len(),
22490 lease_count_before,
22491 );
22492 let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22493 new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22494 let failure = reopened
22495 .spawn_managed_worktree_v2(new_key)
22496 .await
22497 .unwrap_err();
22498 assert_eq!(
22499 failure.code,
22500 NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22501 );
22502 assert_eq!(
22503 reopened
22504 .managed_worktrees
22505 .lock()
22506 .unwrap_or_else(|poisoned| poisoned.into_inner())
22507 .records()
22508 .len(),
22509 lease_count_before,
22510 );
22511 assert_eq!(
22512 reopened
22513 .managed_spawn_replays
22514 .lock()
22515 .unwrap_or_else(|poisoned| poisoned.into_inner())
22516 .len(),
22517 1,
22518 );
22519 std::fs::remove_dir_all(root).unwrap();
22520 }
22521
22522 #[test]
22523 fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22524 let shared = terminal_test_shared();
22525 let request = ManagedWorktreeSpawnRequest {
22526 spawn_spec: spawn_spec_fixture(),
22527 worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22528 };
22529 let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22530 shared.remember_managed_spawn(
22531 request.clone(),
22532 Err(expected.clone()),
22533 Instant::now(),
22534 );
22535
22536 assert_eq!(
22537 shared
22538 .replay_managed_spawn(&request, Instant::now())
22539 .unwrap()
22540 .unwrap(),
22541 Err(expected),
22542 );
22543 assert!(shared
22544 .managed_spawn_replays
22545 .lock()
22546 .unwrap_or_else(|poisoned| poisoned.into_inner())
22547 .is_empty());
22548 }
22549
22550 #[tokio::test]
22551 async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22552 let shared = terminal_test_shared();
22553 let lease = install_managed_lease(&shared);
22554 let raw = shared.spawn_session(
22555 lease.workspace_id.clone(),
22556 agent("claude"),
22557 SessionMode::Pty,
22558 gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22559 None,
22560 ).await.unwrap_err();
22561 assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22562 let mut spec = spawn_spec_fixture();
22563 spec.target.workspace_id = lease.workspace_id.clone();
22564 let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22565 assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22566 let mut selected = spawn_spec_fixture();
22567 selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22568 selected.target.worktree_id = Some(lease.workspace_id);
22569 let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22570 assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22571 assert!(shared.handle.snapshot().sessions.is_empty());
22572 }
22573
22574 #[test]
22575 fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22576 let shared = terminal_test_shared();
22577 let lease = install_managed_lease(&shared);
22578 let snapshot = snapshot_for_wire(
22579 &shared, true, true, true, false, true, true, false, false, true,
22580 );
22581 assert!(snapshot.managed_worktrees.is_empty());
22582 assert!(snapshot.workspaces.iter().all(|workspace| {
22583 workspace.managed_worktree_profiles.is_none()
22584 }));
22585 let mut reply = ResponseEnvelope {
22586 request_id: 1,
22587 result: Ok(NodeResponse::Resync {
22588 event_sequence: 1,
22589 oldest_available_sequence: 1,
22590 snapshot: shared.snapshot(),
22591 events: vec![NodeEventEnvelope {
22592 sequence: 1,
22593 event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
22594 }],
22595 }),
22596 };
22597 project_response_without_managed_worktrees(&mut reply);
22598 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
22599 panic!("projection changed the response kind");
22600 };
22601 assert!(snapshot.managed_worktrees.is_empty());
22602 assert!(events.is_empty());
22603 let mut git = git_snapshot_for_parser();
22604 git.branch = Some(lease.branch.clone());
22605 git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
22606 lease_id: lease.lease_id.clone(),
22607 source_workspace_id: lease.source_workspace_id.clone(),
22608 branch: lease.branch.clone(),
22609 base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
22610 active_session_count: 1,
22611 managed_record_count: 0,
22612 });
22613 let mut inspection_reply = ResponseEnvelope {
22614 request_id: 2,
22615 result: Ok(NodeResponse::WorkspaceInspected {
22616 inspection: WorkspaceInspection {
22617 workspace_id: lease.workspace_id.clone(),
22618 entries: Vec::new(),
22619 tree_truncated: false,
22620 git,
22621 truncation: None,
22622 },
22623 }),
22624 };
22625 project_response_without_managed_worktrees(&mut inspection_reply);
22626 let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
22627 panic!("projection changed the inspection response kind");
22628 };
22629 assert!(inspection.git.managed_worktree.is_none());
22630 let failure = managed_git_worktree_failure(GitWorktreeError {
22631 kind: GitWorktreeErrorKind::Failed,
22632 message: r"git failed at C:\private\managed-target".to_owned(),
22633 });
22634 assert!(!failure.message.contains("private"));
22635 assert!(!failure.message.contains("managed-target"));
22636 }
22637
22638 #[tokio::test]
22639 async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
22640 let root = temporary_workspace_root("managed-restart");
22641 let source = root.join("source");
22642 let allocation = root.join("allocation");
22643 std::fs::create_dir_all(&source).unwrap();
22644 std::fs::create_dir_all(&allocation).unwrap();
22645 run_test_git(&source, &["init"]);
22646 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22647 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22648 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22649 run_test_git(&source, &["add", "seed.txt"]);
22650 run_test_git(&source, &["commit", "-m", "seed"]);
22651 let profile = ManagedWorktreeProfile::new(
22652 WorktreeProfileId::new("default").unwrap(),
22653 crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22654 &allocation,
22655 "gate4agent",
22656 "HEAD",
22657 ManagedWorktreeRetention::Retain,
22658 ).unwrap();
22659 let workspace = WorkspaceConfig::new(
22660 WorkspaceId::new("primary").unwrap(),
22661 &source,
22662 ).unwrap()
22663 .with_worktree_service_mode(WorktreeServiceMode::Managed)
22664 .with_managed_worktree_profile(profile.clone()).unwrap();
22665 let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
22666 let source_text = workspace.canonical_root().to_owned();
22667 let target_text = target.to_string_lossy().into_owned();
22668 let created = create_git_worktree(
22669 &source_text,
22670 &target_text,
22671 "gate4agent/mw-restart",
22672 Some("HEAD"),
22673 ).await.unwrap();
22674 let lease = ManagedWorktreeLeaseRecord {
22675 lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
22676 source_workspace_id: WorkspaceId::new("primary").unwrap(),
22677 workspace_id: WorkspaceId::new("managed-restart").unwrap(),
22678 profile_id: profile.profile_id().clone(),
22679 profile_revision: profile.revision().clone(),
22680 target_root: created.path.clone(),
22681 branch: "gate4agent/mw-restart".to_owned(),
22682 base_commit: created.head.clone(),
22683 expected_head: None,
22684 retention: ManagedWorktreeRetention::Retain,
22685 state: ManagedWorktreeLeaseState::Allocating,
22686 session_holders: Vec::new(),
22687 record_holders: Vec::new(),
22688 cleanup_failure: None,
22689 created_at_unix_ms: 1,
22690 updated_at_unix_ms: 1,
22691 };
22692 let catalog = active_registry().unwrap();
22693 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22694 let shared = NodeShared::new_with_incarnation(
22695 handle,
22696 "fixture-token".to_owned(),
22697 NodeId::new("node-managed-restart").unwrap(),
22698 NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
22699 vec![workspace],
22700 vec![agent("claude")],
22701 ProviderRuntimeStatuses::default(),
22702 None,
22703 Vec::new(),
22704 Vec::new(),
22705 SpawnProfileRegistry::default(),
22706 None,
22707 None,
22708 None,
22709 None,
22710 Vec::new(),
22711 vec![lease.clone()],
22712 Vec::new(),
22713 Vec::new(),
22714 None,
22715 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
22716 );
22717 shared.reconcile_managed_worktrees().await;
22718 let retained = shared.managed_worktrees.lock()
22719 .unwrap_or_else(|poisoned| poisoned.into_inner())
22720 .get(&lease.lease_id).unwrap().clone();
22721 assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
22722 assert!(shared.workspaces.read()
22723 .unwrap_or_else(|poisoned| poisoned.into_inner())
22724 .contains_key(&lease.workspace_id));
22725 let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
22726 assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
22727 assert!(!Path::new(&created.path).exists());
22728 std::fs::remove_dir_all(root).unwrap();
22729 }
22730
22731 fn run_test_git(root: &Path, arguments: &[&str]) {
22732 let output = std::process::Command::new("git")
22733 .arg("-C").arg(root).args(arguments).output().unwrap();
22734 assert!(
22735 output.status.success(),
22736 "git {:?} failed: {}",
22737 arguments,
22738 String::from_utf8_lossy(&output.stderr),
22739 );
22740 }
22741
22742 #[tokio::test]
22743 async fn spawn_spec_validation_fails_before_session_mutation() {
22744 let shared = terminal_test_shared();
22745 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22746 capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
22747 }));
22748 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22749 capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
22750 }));
22751 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
22752 capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
22753 }));
22754
22755 let mut target_mismatch = spawn_spec_fixture();
22756 target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
22757 let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
22758 assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
22759
22760 let mut unknown_profile = spawn_spec_fixture();
22761 unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
22762 unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
22763 let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
22764 assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
22765
22766 let mut revision_mismatch = spawn_spec_fixture();
22767 revision_mismatch.idempotency_key =
22768 SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
22769 revision_mismatch.expected_profile_revision =
22770 crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
22771 let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
22772 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22773
22774 let mut unsupported_materializer = spawn_spec_fixture();
22775 unsupported_materializer.idempotency_key =
22776 SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
22777 unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
22778 value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
22779 };
22780 let failure = shared
22781 .spawn_from_spec(unsupported_materializer)
22782 .await
22783 .unwrap_err();
22784 assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
22785
22786 let failure = shared
22787 .spawn_session_with_deadline(
22788 WorkspaceId::new("primary").unwrap(),
22789 agent("claude"),
22790 SessionMode::Pty,
22791 gate4agent_types::TerminalSize {
22792 rows: 24,
22793 columns: 80,
22794 },
22795 None,
22796 None,
22797 None,
22798 None,
22799 None,
22800 None,
22801 SpawnRecordPolicy::ProviderIdentityOnly,
22802 Some(Instant::now()),
22803 &[],
22804 None,
22805 ApprovalLevel::default(),
22806 None,
22807 None,
22808 )
22809 .await
22810 .unwrap_err();
22811 assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
22812 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22813 assert!(shared.handle.snapshot().sessions.is_empty());
22814 }
22815
22816 #[test]
22817 fn worktree_selection_requires_its_separate_negotiated_capability() {
22818 let mut spec = spawn_spec_fixture();
22819 spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
22820 let request = NodeRequest::SpawnSpec { spec };
22821 let spawn_spec_capability =
22822 CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
22823 assert!(request_uses_unnegotiated_capability(
22824 &request,
22825 &[spawn_spec_capability.clone()],
22826 ));
22827 let worktree_capability =
22828 CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
22829 assert!(request_uses_unnegotiated_capability(
22830 &request,
22831 &[spawn_spec_capability.clone(), worktree_capability.clone()],
22832 ));
22833 let profile_revision_capability =
22834 CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
22835 assert!(request_uses_unnegotiated_capability(
22836 &request,
22837 &[
22838 spawn_spec_capability.clone(),
22839 worktree_capability.clone(),
22840 profile_revision_capability.clone(),
22841 ],
22842 ));
22843 let bundle_materialization_capability =
22850 CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
22851 assert!(request_uses_unnegotiated_capability(
22852 &request,
22853 &[
22854 spawn_spec_capability.clone(),
22855 worktree_capability.clone(),
22856 profile_revision_capability.clone(),
22857 bundle_materialization_capability.clone(),
22858 ],
22859 ));
22860 let history_context_pack_capability =
22861 CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
22862 assert!(!request_uses_unnegotiated_capability(
22863 &request,
22864 &[
22865 spawn_spec_capability,
22866 worktree_capability,
22867 profile_revision_capability,
22868 bundle_materialization_capability,
22869 history_context_pack_capability,
22870 ],
22871 ));
22872 }
22873
22874 #[tokio::test]
22875 async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
22876 let mut shared = terminal_test_shared();
22877 let spec = spawn_spec_fixture();
22878 let receipt = shared
22879 .resolve_spawn_spec(&spec)
22880 .unwrap()
22881 .receipt(shared.incarnation_id, terminal_address(1));
22882 shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
22883
22884 let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
22885 revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22886 shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
22887
22888 assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
22889
22890 let mut revision_conflicting = spec.clone();
22891 revision_conflicting.expected_profile_revision =
22892 crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
22893 let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
22894 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22895
22896 let mut stale_cache_miss = spec.clone();
22897 stale_cache_miss.idempotency_key =
22898 SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
22899 let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
22900 assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
22901
22902 let mut conflicting = spec;
22903 conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
22904 let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
22905 assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22906 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22907 assert!(shared.handle.snapshot().sessions.is_empty());
22908 }
22909
22910 #[tokio::test]
22911 async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
22912 let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
22913 let mut spec = spawn_spec_fixture();
22914 spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
22915 let failure = shared.spawn_from_spec(spec).await.unwrap_err();
22916 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22917
22918 let failure = shared
22919 .create_worktree(
22920 WorkspaceId::new("primary").unwrap(),
22921 WorkspaceId::new("new-worktree").unwrap(),
22922 "not-an-absolute-path".to_owned(),
22923 "invalid branch".to_owned(),
22924 None,
22925 )
22926 .await
22927 .unwrap_err();
22928 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22929
22930 let failure = shared
22931 .remove_worktree(
22932 WorkspaceId::new("primary").unwrap(),
22933 "not-an-absolute-path".to_owned(),
22934 )
22935 .await
22936 .unwrap_err();
22937 assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
22938 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
22939 assert!(shared.handle.snapshot().sessions.is_empty());
22940 }
22941
22942 #[tokio::test]
22943 async fn manual_worktree_registration_failure_removes_the_created_worktree() {
22944 let root = temporary_workspace_root("manual-worktree-compensation");
22945 let source = root.join("source");
22946 let target = root.join("target");
22947 let invalid_state_parent = root.join("state-parent-is-a-file");
22948 let invalid_state_path = invalid_state_parent.join("state.json");
22949 std::fs::create_dir_all(&source).unwrap();
22950 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
22951 run_test_git(&source, &["init"]);
22952 run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22953 run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22954 std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22955 run_test_git(&source, &["add", "seed.txt"]);
22956 run_test_git(&source, &["commit", "-m", "seed"]);
22957
22958 let catalog = active_registry().unwrap();
22959 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22960 let workspace = WorkspaceConfig::new(
22961 WorkspaceId::new("primary").unwrap(),
22962 &source,
22963 ).unwrap();
22964 let mut shared = NodeShared::new(
22965 handle,
22966 "fixture-token".to_owned(),
22967 NodeId::new("node-worktree-compensation").unwrap(),
22968 vec![workspace],
22969 vec![agent("claude")],
22970 );
22971 shared.state_path = Some(invalid_state_path);
22972
22973 let error = shared.create_worktree(
22974 WorkspaceId::new("primary").unwrap(),
22975 WorkspaceId::new("compensated").unwrap(),
22976 target.to_string_lossy().into_owned(),
22977 "gate4agent/compensated".to_owned(),
22978 Some("HEAD".to_owned()),
22979 ).await.unwrap_err();
22980
22981 assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
22982 assert!(!target.exists());
22983 assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
22984 .iter()
22985 .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
22986 std::fs::remove_dir_all(root).unwrap();
22987 }
22988
22989 #[test]
22990 fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
22991 fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
22992 NativeGitWorktreeSnapshot {
22993 path: path.to_owned(),
22994 head: "0123456789abcdef".to_owned(),
22995 branch: Some("refs/heads/test".to_owned()),
22996 is_bare,
22997 is_main,
22998 locked: false,
22999 lock_reason: None,
23000 prunable: false,
23001 prunable_reason: None,
23002 workspace_id: None,
23003 }
23004 }
23005
23006 let source = r"C:\repo";
23007 let selected = r"C:\repo-worktree";
23008 let listed = vec![
23009 worktree(source, true, false),
23010 worktree(selected, false, false),
23011 ];
23012 assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23013
23014 let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23015 assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23016
23017 let bare = vec![
23018 worktree(source, true, false),
23019 worktree(selected, false, true),
23020 ];
23021 let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23022 assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23023
23024 let foreign_failure =
23025 validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23026 assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23027 }
23028
23029 fn terminal_frame(sequence: u64) -> TerminalFrame {
23030 TerminalFrame {
23031 sequence,
23032 size: gate4agent_types::TerminalSize {
23033 rows: 24,
23034 columns: 80,
23035 },
23036 cursor_row: 2,
23037 cursor_column: 3,
23038 contents: format!("frame-{sequence}"),
23039 formatted: vec![1, 2, 3],
23040 scrollback_formatted: Vec::new(),
23041 alternate_screen: false,
23042 mouse_protocol_enabled: false,
23043 mouse_protocol_encoding: Default::default(),
23044 produced_at_unix_ms: 0,
23045 screen_state: gate4agent_types::PtyScreenState::default(),
23046 bracketed_paste: None,
23047 }
23048 }
23049
23050 #[test]
23051 fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23052 let shared = terminal_test_shared();
23053 let mut receiver = shared.terminal_event_tx.subscribe();
23054 let original = terminal_address(1);
23055
23056 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23057 let first = receiver.try_recv().unwrap();
23058 assert_eq!(first.len(), 1);
23059 assert!(matches!(
23060 &first[0].event,
23061 NodeEvent::TerminalFrame { address, frame }
23062 if address == &original && frame.sequence == 7
23063 ));
23064
23065 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23066 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23067 assert!(matches!(
23068 receiver.try_recv(),
23069 Err(broadcast::error::TryRecvError::Empty)
23070 ));
23071
23072 shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23073 let advanced = receiver.try_recv().unwrap();
23074 assert_eq!(advanced[0].sequence, 2);
23075
23076 let resumed = terminal_address(2);
23077 shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23078 let reset = receiver.try_recv().unwrap();
23079 assert_eq!(reset[0].sequence, 3);
23080 assert!(matches!(
23081 &reset[0].event,
23082 NodeEvent::TerminalFrame { address, frame }
23083 if address == &resumed && frame.sequence == 1
23084 ));
23085 shared.clear_terminal_frame_watermark(&resumed);
23086 assert!(!shared
23087 .terminal_frame_watermarks
23088 .lock()
23089 .unwrap_or_else(|poisoned| poisoned.into_inner())
23090 .contains_key(&resumed.session.instance_id));
23091 }
23092
23093 #[test]
23094 fn terminal_frame_batches_replace_without_entering_node_event_history() {
23095 let shared = terminal_test_shared();
23096 let mut receiver = shared.terminal_event_tx.subscribe();
23097 let address = terminal_address(1);
23098 let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23099
23100 for sequence in 1..=count {
23101 shared.publish_terminal_frame_candidates(vec![(
23102 address.clone(),
23103 terminal_frame(sequence),
23104 )]);
23105 }
23106
23107 let history = shared
23108 .history
23109 .lock()
23110 .unwrap_or_else(|poisoned| poisoned.into_inner());
23111 assert!(history.events.is_empty());
23112 assert_eq!(history.last_sequence, count);
23113 assert_eq!(history.replay_floor_sequence, 1);
23114 drop(history);
23115 let NodeResponse::Resync {
23116 event_sequence,
23117 oldest_available_sequence,
23118 events,
23119 ..
23120 } = shared.resync(0) else {
23121 unreachable!("resync helper returned another response")
23122 };
23123 assert_eq!(event_sequence, count);
23124 assert_eq!(oldest_available_sequence, 1);
23125 assert!(events.is_empty());
23126 assert!(matches!(
23127 resync_required_event(&shared).event,
23128 NodeEvent::ResyncRequired {
23129 oldest_available_sequence: 1,
23130 }
23131 ));
23132 assert!(matches!(
23133 receiver.try_recv(),
23134 Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23135 ));
23136 let latest = receiver.try_recv().unwrap();
23137 assert_eq!(latest.len(), 1);
23138 assert_eq!(latest[0].sequence, count);
23139 }
23140
23141 #[test]
23142 fn real_durable_eviction_advances_authoritative_replay_floor() {
23143 let shared = terminal_test_shared();
23144 for _ in 0..=NODE_EVENT_HISTORY_MAX {
23145 shared.publish(NodeEvent::ControllerChanged { controller: None });
23146 }
23147
23148 let NodeResponse::Resync {
23149 event_sequence,
23150 oldest_available_sequence,
23151 events,
23152 ..
23153 } = shared.resync(0) else {
23154 unreachable!("resync helper returned another response")
23155 };
23156 assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23157 assert_eq!(oldest_available_sequence, 2);
23158 assert_eq!(events.first().map(|event| event.sequence), Some(2));
23159 assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23160 assert!(matches!(
23161 resync_required_event(&shared).event,
23162 NodeEvent::ResyncRequired {
23163 oldest_available_sequence: 2,
23164 }
23165 ));
23166 }
23167
23168 #[test]
23169 fn legacy_terminal_frame_connection_has_no_live_subscription() {
23170 let shared = terminal_test_shared();
23171 let without_capability = terminal_event_subscription(&shared, false);
23172 let with_capability = terminal_event_subscription(&shared, true);
23173 assert!(without_capability.is_none());
23174 assert!(with_capability.is_some());
23175 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23176 capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23177 }));
23178 }
23179
23180 #[test]
23181 fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23182 let mut pending = Vec::new();
23183 let mut event_sequence = 0_u64;
23184 for frame_sequence in 1..=4 {
23185 for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23186 event_sequence += 1;
23187 let mut address = terminal_address(1);
23188 address.session.instance_id = AgentInstanceId(instance);
23189 queue_connection_event(
23190 &mut pending,
23191 NodeEventEnvelope {
23192 sequence: event_sequence,
23193 event: NodeEvent::TerminalFrame {
23194 address,
23195 frame: terminal_frame(frame_sequence),
23196 },
23197 },
23198 0,
23199 );
23200 }
23201 }
23202 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23203 pending.sort_unstable_by_key(|event| event.sequence);
23204
23205 let mut uninterrupted = pending.clone();
23206 let first_burst = connection_event_burst_len(uninterrupted.len());
23207 uninterrupted.drain(..first_burst);
23208 let second_burst = connection_event_burst_len(uninterrupted.len());
23209 uninterrupted.drain(..second_burst);
23210 assert!(uninterrupted.is_empty());
23211
23212 let (frame_tx, mut frame_rx) =
23213 mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23214 frame_tx
23215 .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23216 request_id: 77,
23217 request: NodeRequest::Snapshot,
23218 })))
23219 .unwrap();
23220 let burst_len = connection_event_burst_len(pending.len());
23221 assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23222 pending.drain(..burst_len);
23223 assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23224 assert!(matches!(
23225 frame_rx.try_recv(),
23226 Ok(Ok(ClientFrame::Request(RequestEnvelope {
23227 request_id: 77,
23228 request: NodeRequest::Snapshot,
23229 })))
23230 ));
23231 }
23232
23233 #[test]
23234 fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23235 let mut pending = Vec::new();
23236 for sequence in [7_u64, 8] {
23237 let mut address = terminal_address(1);
23238 address.session.instance_id = AgentInstanceId(sequence);
23239 queue_connection_event(
23240 &mut pending,
23241 NodeEventEnvelope {
23242 sequence,
23243 event: NodeEvent::TerminalFrame {
23244 address,
23245 frame: terminal_frame(sequence),
23246 },
23247 },
23248 0,
23249 );
23250 }
23251 let mut discard_through = 0;
23252 apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23253 assert!(pending.is_empty());
23254
23255 let replayed = NodeEventEnvelope {
23256 sequence: 8,
23257 event: NodeEvent::TerminalFrame {
23258 address: terminal_address(1),
23259 frame: terminal_frame(8),
23260 },
23261 };
23262 let advanced = NodeEventEnvelope {
23263 sequence: 9,
23264 event: NodeEvent::TerminalFrame {
23265 address: terminal_address(2),
23266 frame: terminal_frame(1),
23267 },
23268 };
23269 queue_connection_event_batch(
23270 &mut pending,
23271 &[replayed, advanced.clone()],
23272 discard_through,
23273 );
23274 assert_eq!(pending, vec![advanced]);
23275 }
23276
23277 fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23278 ManagedSessionRecord {
23279 record_id: SessionRecordId::new(record_id).unwrap(),
23280 display_name: format!("{provider} record"),
23281 provider: agent(provider),
23282 mode: SessionMode::Pty,
23283 state: ManagedSessionState::Unavailable,
23284 workspace_id: WorkspaceId::new("primary").unwrap(),
23285 canonical_root: opaque_windows_path(
23286 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23287 ),
23288 provider_session: None,
23289 active_session: None,
23290 environment_profile: None,
23291 bundle: None,
23292 context_id: None,
23293 context: None,
23294 exported_context: None,
23295 task_binding: None,
23296 created_at_unix_ms: 1,
23297 updated_at_unix_ms: 1,
23298 last_error: None,
23299 }
23300 }
23301
23302 fn retention_test_record(
23303 workspace_id: &str,
23304 record_id: &str,
23305 state: ManagedSessionState,
23306 updated_at_unix_ms: u64,
23307 ) -> ManagedSessionRecord {
23308 ManagedSessionRecord {
23309 record_id: SessionRecordId::new(record_id).unwrap(),
23310 display_name: format!("{record_id} display"),
23311 provider: agent("claude"),
23312 mode: SessionMode::Pty,
23313 state,
23314 workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23315 canonical_root: opaque_windows_path(
23316 std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23317 ),
23318 provider_session: None,
23319 active_session: None,
23320 environment_profile: None,
23321 bundle: None,
23322 context_id: None,
23323 context: None,
23324 exported_context: None,
23325 task_binding: None,
23326 created_at_unix_ms: updated_at_unix_ms,
23327 updated_at_unix_ms,
23328 last_error: None,
23329 }
23330 }
23331
23332 fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23340 vec![
23341 retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23342 retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23343 retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23344 retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23345 retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23346 retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23347 retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23348 retention_test_record(
23349 "workspace-a",
23350 "decoy-pending",
23351 ManagedSessionState::IdentityPending,
23352 now - 500_000,
23353 ),
23354 retention_test_record(
23355 "workspace-a",
23356 "decoy-dormant",
23357 ManagedSessionState::Dormant,
23358 now - 500_000,
23359 ),
23360 ]
23361 }
23362
23363 fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23364 let root = temporary_workspace_root("session-record-retention");
23365 std::fs::create_dir_all(&root).unwrap();
23366 let catalog = active_registry().unwrap();
23367 let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23368 let workspace_a =
23369 WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23370 let workspace_b =
23371 WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23372 let mut shared = NodeShared::new(
23373 handle,
23374 "fixture-token".to_owned(),
23375 NodeId::new("node-retention-test").unwrap(),
23376 vec![workspace_a, workspace_b],
23377 vec![agent("claude")],
23378 );
23379 shared.state_path = Some(root.join("node-state-v10.json"));
23380 let now = unix_time_ms();
23381 for record in retention_fixture_records(now) {
23382 shared.insert_record(record).unwrap();
23383 }
23384 shared
23385 .persist_state_locked()
23386 .expect("fixture-only, empty-history persist against a fresh temp state path");
23387 (shared, runtime, root, now)
23388 }
23389
23390 fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23391 shared
23392 .history
23393 .lock()
23394 .unwrap_or_else(|poisoned| poisoned.into_inner())
23395 .events
23396 .iter()
23397 .filter_map(|envelope| match &envelope.event {
23398 NodeEvent::SessionRecordRemoved { record_id } => {
23399 Some(record_id.as_str().to_owned())
23400 }
23401 _ => None,
23402 })
23403 .collect()
23404 }
23405
23406 fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23407 shared
23408 .session_records
23409 .lock()
23410 .unwrap_or_else(|poisoned| poisoned.into_inner())
23411 .records
23412 .keys()
23413 .map(|record_id| record_id.as_str().to_owned())
23414 .collect()
23415 }
23416
23417 fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23418 values.iter().map(|value| (*value).to_owned()).collect()
23419 }
23420
23421 #[tokio::test]
23422 async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23423 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23424 shared.session_record_retention = SessionRecordRetentionConfig {
23425 age_ms: 0,
23426 keep_per_workspace: 1,
23427 };
23428 shared.retire_unavailable_session_records().await;
23429
23430 assert_eq!(
23431 retention_remaining_record_ids(&shared),
23432 owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23433 );
23434 assert_eq!(
23435 retention_removed_record_ids(&shared),
23436 owned(&["a1", "a2", "b1", "b2"]),
23437 );
23438 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23439
23440 drop(shared);
23441 drop(runtime);
23442 std::fs::remove_dir_all(root).unwrap();
23443 }
23444
23445 #[tokio::test]
23451 async fn session_record_retention_both_set_retires_only_the_intersection() {
23452 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23453 shared.session_record_retention = SessionRecordRetentionConfig {
23454 age_ms: 250_000,
23455 keep_per_workspace: 1,
23456 };
23457 shared.retire_unavailable_session_records().await;
23458
23459 assert_eq!(
23460 retention_remaining_record_ids(&shared),
23461 owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23462 );
23463 assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23464 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23465
23466 drop(shared);
23467 drop(runtime);
23468 std::fs::remove_dir_all(root).unwrap();
23469 }
23470
23471 #[tokio::test]
23472 async fn session_record_retention_both_disabled_is_a_no_op() {
23473 let (mut shared, runtime, root, _now) = retention_fixture_shared();
23474 shared.session_record_retention = SessionRecordRetentionConfig::default();
23475 shared.retire_unavailable_session_records().await;
23476
23477 assert_eq!(
23478 retention_remaining_record_ids(&shared),
23479 owned(&[
23480 "a1", "a2", "a3", "b1", "b2", "b3",
23481 "decoy-live", "decoy-pending", "decoy-dormant",
23482 ]),
23483 );
23484 assert!(retention_removed_record_ids(&shared).is_empty());
23485 assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23486
23487 drop(shared);
23488 drop(runtime);
23489 std::fs::remove_dir_all(root).unwrap();
23490 }
23491
23492 #[test]
23493 fn session_record_context_export_selects_one_exact_history_identity() {
23494 let candidates = vec![
23495 HistoryCandidateSummary {
23496 id: "candidate-other".to_owned(),
23497 session_id_hint: "provider-other".to_owned(),
23498 modified_at_unix_ms: Some(1),
23499 },
23500 HistoryCandidateSummary {
23501 id: "candidate-exact".to_owned(),
23502 session_id_hint: "provider-exact".to_owned(),
23503 modified_at_unix_ms: Some(2),
23504 },
23505 ];
23506 assert_eq!(
23507 select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23508 "candidate-exact",
23509 );
23510 }
23511
23512 #[test]
23513 fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23514 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23515 id: id.to_owned(),
23516 session_id_hint: hint.to_owned(),
23517 modified_at_unix_ms: None,
23518 };
23519 let missing = select_history_candidate(
23520 &[candidate("candidate-other", "provider-other")],
23521 Some("provider-exact"),
23522 )
23523 .unwrap_err();
23524 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23525 let ambiguous = select_history_candidate(
23526 &[
23527 candidate("candidate-a", "provider-exact"),
23528 candidate("candidate-b", "provider-exact"),
23529 ],
23530 Some("provider-exact"),
23531 )
23532 .unwrap_err();
23533 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23534 }
23535
23536 #[test]
23544 fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23545 let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23546 id: id.to_owned(),
23547 session_id_hint: hint.to_owned(),
23548 modified_at_unix_ms: None,
23549 };
23550 assert_eq!(
23551 select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23552 .unwrap(),
23553 "candidate-sole",
23554 );
23555 let missing = select_history_candidate(&[], None).unwrap_err();
23556 assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23557 let ambiguous = select_history_candidate(
23558 &[
23559 candidate("candidate-a", "sidecar-session-a"),
23560 candidate("candidate-b", "sidecar-session-b"),
23561 ],
23562 None,
23563 )
23564 .unwrap_err();
23565 assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23566 }
23567
23568 #[test]
23569 fn session_record_context_export_revalidation_rejects_record_change() {
23570 let session = terminal_address(1);
23571 let identity = ProviderSessionIdentity {
23572 key: ProviderSessionKey::SessionId,
23573 id: "provider-exact".to_owned(),
23574 transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23575 };
23576 let mut expected = record("claude", "record-context-export");
23577 expected.state = ManagedSessionState::Live;
23578 expected.provider_session = Some(identity.clone());
23579 expected.active_session = Some(session.clone());
23580 let binding = SessionBinding {
23581 workspace_id: session.workspace_id.clone(),
23582 generation: session.session.generation,
23583 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23584 pending_resume: None,
23585 record_id: Some(expected.record_id.clone()),
23586 managed_worktree_lease_id: None,
23587 environment_profile: None,
23588 bundle: None,
23589 context: None,
23590 materialization_id: None,
23591 };
23592 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23593 assert!(session_record_context_export_binding_is_exact(
23594 &expected,
23595 &expected,
23596 Some(&identity),
23597 &session,
23598 &binding,
23599 &expected.provider,
23600 ¤t_root,
23601 false,
23602 ));
23603 let mut changed = expected.clone();
23604 changed.updated_at_unix_ms += 1;
23605 assert!(!session_record_context_export_binding_is_exact(
23606 &expected,
23607 &changed,
23608 Some(&identity),
23609 &session,
23610 &binding,
23611 &expected.provider,
23612 ¤t_root,
23613 false,
23614 ));
23615 assert!(!session_record_context_export_binding_is_exact(
23620 &expected,
23621 &changed,
23622 Some(&identity),
23623 &session,
23624 &binding,
23625 &expected.provider,
23626 ¤t_root,
23627 true,
23628 ));
23629 let mut detached = expected.clone();
23630 detached.state = ManagedSessionState::Dormant;
23631 detached.active_session = None;
23632 detached.updated_at_unix_ms += 1;
23633 assert!(!session_record_context_export_binding_is_exact(
23634 &expected,
23635 &detached,
23636 Some(&identity),
23637 &session,
23638 &binding,
23639 &expected.provider,
23640 ¤t_root,
23641 false,
23642 ));
23643 assert!(session_record_context_export_binding_is_exact(
23644 &expected,
23645 &detached,
23646 Some(&identity),
23647 &session,
23648 &binding,
23649 &expected.provider,
23650 ¤t_root,
23651 true,
23652 ));
23653 }
23654
23655 #[test]
23662 fn session_record_context_export_binding_is_exact_without_identity() {
23663 let session = terminal_address(1);
23664 let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
23665 expected.state = ManagedSessionState::Live;
23666 expected.active_session = Some(session.clone());
23667 let binding = SessionBinding {
23668 workspace_id: session.workspace_id.clone(),
23669 generation: session.session.generation,
23670 runtime_policy: ProviderRuntimePolicy::raw_pty(),
23671 pending_resume: None,
23672 record_id: Some(expected.record_id.clone()),
23673 managed_worktree_lease_id: None,
23674 environment_profile: None,
23675 bundle: None,
23676 context: None,
23677 materialization_id: None,
23678 };
23679 let current_root = windows_path_text(&expected.canonical_root).to_owned();
23680 assert!(session_record_context_export_binding_is_exact(
23681 &expected,
23682 &expected,
23683 None,
23684 &session,
23685 &binding,
23686 &expected.provider,
23687 ¤t_root,
23688 false,
23689 ));
23690 let mut drifted = expected.clone();
23694 drifted.provider_session = Some(ProviderSessionIdentity {
23695 key: ProviderSessionKey::SessionId,
23696 id: "unexpected-identity".to_owned(),
23697 transcript_path: None,
23698 });
23699 assert!(!session_record_context_export_binding_is_exact(
23700 &expected,
23701 &drifted,
23702 None,
23703 &session,
23704 &binding,
23705 &expected.provider,
23706 ¤t_root,
23707 false,
23708 ));
23709 }
23710
23711 #[test]
23712 fn session_record_context_export_preflight_requires_live_usable_source() {
23713 let mut source = record("claude", "record-context-export-preflight");
23714 source.state = ManagedSessionState::Live;
23715 assert!(session_record_context_export_source_is_usable(
23716 &source,
23717 &SessionStatus::Running,
23718 false,
23719 ));
23720 assert!(session_record_context_export_source_is_usable(
23721 &source,
23722 &SessionStatus::Exited { exit_code: Some(0) },
23723 false,
23724 ));
23725
23726 source.state = ManagedSessionState::Dormant;
23727 assert!(!session_record_context_export_source_is_usable(
23728 &source,
23729 &SessionStatus::Running,
23730 false,
23731 ));
23732 assert!(session_record_context_export_source_is_usable(
23736 &source,
23737 &SessionStatus::Exited { exit_code: Some(0) },
23738 true,
23739 ));
23740 assert!(!session_record_context_export_source_is_usable(
23741 &source,
23742 &SessionStatus::Exited { exit_code: Some(0) },
23743 false,
23744 ));
23745 source.state = ManagedSessionState::Live;
23746 assert!(!session_record_context_export_source_is_usable(
23747 &source,
23748 &SessionStatus::Starting,
23749 false,
23750 ));
23751 }
23752
23753 #[test]
23754 fn session_record_context_export_final_conflict_leaves_catalog_empty() {
23755 let catalog = active_registry().unwrap();
23756 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23757 let workspace = WorkspaceConfig::new(
23758 WorkspaceId::new("primary").unwrap(),
23759 std::env::current_dir().unwrap(),
23760 )
23761 .unwrap();
23762 let shared = NodeShared::new(
23763 handle,
23764 "fixture-token".to_owned(),
23765 NodeId::new("node-context-export-conflict").unwrap(),
23766 vec![workspace],
23767 vec![agent("claude")],
23768 );
23769 let history = HistorySessionRecord {
23770 session_id: "provider-exact".to_owned(),
23771 title: None,
23772 cwd: None,
23773 model: None,
23774 message_count: 1,
23775 completed_turn_count: None,
23776 total_tokens: 0,
23777 messages: vec![gate4agent_types::HistoryMessageRecord {
23778 role: gate4agent_types::HistoryMessageRole::User,
23779 text: "bounded context".to_owned(),
23780 }],
23781 };
23782 let pack = NodeContextPack::export(
23783 ContextPackLineageReceipt {
23784 source_node_id: shared.node_id.clone(),
23785 source_session: terminal_address(1),
23786 source_provider: agent("claude"),
23787 },
23788 &history,
23789 )
23790 .unwrap();
23791 let context_id = pack.receipt().id.clone();
23792 let session = terminal_address(1);
23793 let identity = ProviderSessionIdentity {
23794 key: ProviderSessionKey::SessionId,
23795 id: history.session_id.clone(),
23796 transcript_path: None,
23797 };
23798 let mut expected = record("claude", "record-context-export-final-conflict");
23799 expected.state = ManagedSessionState::Live;
23800 expected.provider_session = Some(identity.clone());
23801 expected.active_session = Some(session.clone());
23802 let mut changed = expected.clone();
23803 changed.updated_at_unix_ms += 1;
23804 shared.insert_record(changed).unwrap();
23805
23806 let error = shared
23807 .commit_context_pack_for_session_record(
23808 &expected,
23809 Some(&identity),
23810 &session,
23811 "candidate-exact",
23812 &history,
23813 pack,
23814 false,
23815 )
23816 .unwrap_err();
23817
23818 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
23819 assert!(shared
23820 .context_catalog
23821 .read()
23822 .unwrap_or_else(|poisoned| poisoned.into_inner())
23823 .get(&context_id)
23824 .is_none());
23825 }
23826
23827 #[tokio::test]
23828 async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
23829 let root = temporary_workspace_root("session-task-binding");
23830 std::fs::create_dir_all(&root).unwrap();
23831 let state_path = root.join("node-state.json");
23832 let catalog = active_registry().unwrap();
23833 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23834 let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
23835 let node_id = NodeId::new("node-1").unwrap();
23836 let mut shared = NodeShared::new(
23837 handle,
23838 "fixture-token".to_owned(),
23839 node_id.clone(),
23840 vec![workspace],
23841 vec![agent("claude")],
23842 );
23843 shared.state_path = Some(state_path.clone());
23844 let record = record("claude", "sr-task");
23845 let record_id = record.record_id.clone();
23846 shared.insert_record(record).unwrap();
23847 shared.persist_state().unwrap();
23848 shared
23849 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
23850 .unwrap();
23851
23852 let minted = process_request_inner(
23853 &shared,
23854 77,
23855 ClientRole::Operator,
23856 NodeRequest::SetSessionTask {
23857 record_id: record_id.clone(),
23858 expected_revision: 0,
23859 target: SessionTaskTargetV1::New,
23860 },
23861 )
23862 .await
23863 .unwrap();
23864 let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
23865 panic!("session task mutation returned another response");
23866 };
23867 let minted_binding = minted.task_binding.clone().unwrap();
23868 let minted_task_id = minted_binding.task_id.clone().unwrap();
23869 assert_eq!(minted_binding.revision, 1);
23870 assert!(minted_task_id.as_str().starts_with("task-"));
23871 assert_eq!(minted_task_id.as_str().len(), 29);
23872
23873 let event_count = shared.current_sequence();
23874 let repeated = process_request_inner(
23875 &shared,
23876 77,
23877 ClientRole::Operator,
23878 NodeRequest::SetSessionTask {
23879 record_id: record_id.clone(),
23880 expected_revision: 1,
23881 target: SessionTaskTargetV1::Existing {
23882 task_id: minted_task_id.clone(),
23883 },
23884 },
23885 )
23886 .await
23887 .unwrap();
23888 assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
23889 assert_eq!(shared.current_sequence(), event_count);
23890
23891 let stale = process_request_inner(
23892 &shared,
23893 77,
23894 ClientRole::Operator,
23895 NodeRequest::SetSessionTask {
23896 record_id: record_id.clone(),
23897 expected_revision: 0,
23898 target: SessionTaskTargetV1::Existing {
23899 task_id: minted_task_id,
23900 },
23901 },
23902 )
23903 .await
23904 .unwrap_err();
23905 assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
23906
23907 let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
23908 let assigned = process_request_inner(
23909 &shared,
23910 77,
23911 ClientRole::Operator,
23912 NodeRequest::SetSessionTask {
23913 record_id: record_id.clone(),
23914 expected_revision: 1,
23915 target: SessionTaskTargetV1::Existing {
23916 task_id: assigned_id.clone(),
23917 },
23918 },
23919 )
23920 .await
23921 .unwrap();
23922 let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
23923 panic!("session task assignment returned another response");
23924 };
23925 assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
23926 assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
23927
23928 let cleared = process_request_inner(
23929 &shared,
23930 77,
23931 ClientRole::Operator,
23932 NodeRequest::SetSessionTask {
23933 record_id: record_id.clone(),
23934 expected_revision: 2,
23935 target: SessionTaskTargetV1::Clear,
23936 },
23937 )
23938 .await
23939 .unwrap();
23940 let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
23941 panic!("session task clear returned another response");
23942 };
23943 assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
23944 assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
23945 assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
23946 assert_eq!(
23947 cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
23948 cleared.updated_at_unix_ms,
23949 );
23950
23951 let persisted = std::fs::read(&state_path).unwrap();
23952 assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
23953 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
23954 assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
23955 std::fs::remove_dir_all(root).unwrap();
23956 }
23957
23958 #[test]
23959 fn session_task_capability_strips_snapshots_events_and_replies() {
23960 let mut bound = record("claude", "sr-task-projection");
23961 bound.updated_at_unix_ms = 2;
23962 bound.task_binding = Some(SessionTaskBindingV1 {
23963 revision: 1,
23964 task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
23965 changed_at_unix_ms: 2,
23966 });
23967 let request = NodeRequest::SetSessionTask {
23968 record_id: bound.record_id.clone(),
23969 expected_revision: 1,
23970 target: SessionTaskTargetV1::Clear,
23971 };
23972 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23973 capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
23974 }));
23975 assert!(!request_uses_unnegotiated_capability(
23976 &request,
23977 &baseline_capabilities().unwrap(),
23978 ));
23979 assert!(request_uses_unnegotiated_capability(&request, &[]));
23980
23981 let mut snapshot = terminal_test_shared().snapshot();
23982 snapshot.session_records.push(bound.clone());
23983 let mut reply = ResponseEnvelope {
23984 request_id: 1,
23985 result: Ok(NodeResponse::Resync {
23986 event_sequence: 1,
23987 oldest_available_sequence: 1,
23988 snapshot,
23989 events: vec![NodeEventEnvelope {
23990 sequence: 1,
23991 event: NodeEvent::SessionRecordUpserted {
23992 record: bound.clone(),
23993 },
23994 }],
23995 }),
23996 };
23997 project_response_without_session_task_binding(&mut reply);
23998 let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23999 panic!("task projection changed the response kind");
24000 };
24001 assert!(snapshot.session_records[0].task_binding.is_none());
24002 let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24003 panic!("task projection changed the event kind");
24004 };
24005 assert!(record.task_binding.is_none());
24006
24007 let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24008 sequence: 2,
24009 event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24010 });
24011 let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24012 panic!("task event projection changed the event kind");
24013 };
24014 assert!(record.task_binding.is_none());
24015
24016 let mut direct = ResponseEnvelope {
24017 request_id: 2,
24018 result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24019 };
24020 project_response_without_session_task_binding(&mut direct);
24021 let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24022 panic!("task reply projection changed the response kind");
24023 };
24024 assert!(record.task_binding.is_none());
24025 }
24026
24027 #[test]
24028 fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24029 assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24030 let workspace = WorkspaceConfig::new(
24031 WorkspaceId::new("primary").unwrap(),
24032 std::env::current_dir().unwrap(),
24033 )
24034 .unwrap();
24035 let config = NodeServerConfig::new(
24036 DEFAULT_NODE_ENDPOINT,
24037 "fixture-token",
24038 NodeId::new("node-1").unwrap(),
24039 [workspace],
24040 )
24041 .unwrap();
24042 assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24043 }
24044
24045 #[cfg(all(feature = "fixture", windows))]
24046 #[test]
24047 fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24048 let root = temporary_workspace_root("provider-bundle-argv-hold");
24049 let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24050 std::fs::create_dir_all(&root).unwrap();
24051 std::fs::create_dir_all(&outside).unwrap();
24052 let proof_path = root.join("provider.proof");
24053 let release_signal = root.join("release.signal");
24054 let config = NodeServerConfig::new(
24055 format!(
24056 r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24057 std::process::id(),
24058 ),
24059 "fixture-token",
24060 NodeId::new("provider-bundle-hold-node").unwrap(),
24061 [WorkspaceConfig::new(
24062 WorkspaceId::new("primary").unwrap(),
24063 std::env::current_dir().unwrap(),
24064 )
24065 .unwrap()],
24066 )
24067 .unwrap()
24068 .with_spawn_profiles(SpawnProfileRegistry::new([
24069 crate::protocol::SpawnProfileDefaults {
24070 profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24071 revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24072 .unwrap(),
24073 provider: agent("kimi"),
24074 mode: SessionMode::Pty,
24075 terminal_size: gate4agent_types::TerminalSize {
24076 rows: 24,
24077 columns: 80,
24078 },
24079 prompt: None,
24080 bundle_id: None,
24081 context_id: None,
24082 environment_profile_id: None,
24083 },
24084 ])
24085 .unwrap());
24086 let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24087 config,
24088 agent("kimi"),
24089 proof_path.clone(),
24090 release_signal.clone(),
24091 )
24092 .unwrap();
24093 assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24094
24095 let spec = NodeServer::provider_bundle_argv_fixture_spec(
24096 agent("kimi"),
24097 proof_path.clone(),
24098 Some(release_signal.clone()),
24099 )
24100 .unwrap();
24101 let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24102 let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24103 let release_wait = wrapper
24104 .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24105 .unwrap();
24106 let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24107 assert!(proof_write < release_wait);
24108 assert!(release_wait < fixture_ready);
24109 assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24110 assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24111 assert_eq!(
24112 &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24113 &[
24114 proof_path.to_string_lossy().into_owned(),
24115 release_signal.to_string_lossy().into_owned(),
24116 ],
24117 );
24118
24119 let error = NodeServer::provider_bundle_argv_fixture_spec(
24120 agent("kimi"),
24121 proof_path,
24122 Some(outside.join("release.signal")),
24123 )
24124 .unwrap_err();
24125 assert!(matches!(
24126 error,
24127 NodeServerError::Registry(message)
24128 if message == "provider bundle argv release path must share the proof parent"
24129 ));
24130 drop(server);
24131 std::fs::remove_dir_all(root).unwrap();
24132 std::fs::remove_dir_all(outside).unwrap();
24133 }
24134
24135 #[cfg(feature = "fixture")]
24136 #[test]
24137 fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24138 let root = temporary_workspace_root("context-fixture-registry");
24139 std::fs::create_dir_all(&root).unwrap();
24140 let proof_path = root.join("context.proof");
24141 let config = NodeServerConfig::new(
24142 format!(
24143 r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24144 std::process::id(),
24145 ),
24146 "fixture-token",
24147 NodeId::new("context-fixture-node").unwrap(),
24148 [WorkspaceConfig::new(
24149 WorkspaceId::new("primary").unwrap(),
24150 std::env::current_dir().unwrap(),
24151 )
24152 .unwrap()],
24153 )
24154 .unwrap();
24155 let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24156 let mut enabled = server
24157 .shared
24158 .enabled_providers
24159 .iter()
24160 .map(AgentId::as_str)
24161 .collect::<Vec<_>>();
24162 enabled.sort_unstable();
24163 assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24164 for provider in &server.shared.enabled_providers {
24165 assert!(server
24166 .shared
24167 .provider_adapter_contracts
24168 .iter()
24169 .any(|contract| {
24170 &contract.provider == provider
24171 && contract.family == AdapterFamily::History
24172 }));
24173 }
24174 drop(server);
24175 std::fs::remove_dir_all(root).unwrap();
24176 }
24177
24178 #[cfg(feature = "fixture")]
24179 #[test]
24180 fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24181 let root = temporary_workspace_root("monitoring-context-fixture");
24182 std::fs::create_dir_all(&root).unwrap();
24183 let proof_path = root.join("context.proof");
24184 let standard = NodeServer::context_pack_fixture_catalog(
24185 proof_path.clone(),
24186 None,
24187 false,
24188 ).unwrap();
24189 let monitoring = NodeServer::context_pack_fixture_catalog(
24190 proof_path.clone(),
24191 None,
24192 true,
24193 ).unwrap();
24194 let claude = AgentId::new("claude").unwrap();
24195 let standard_claude = standard.get(&claude).unwrap();
24196 let monitoring_claude = monitoring.get(&claude).unwrap();
24197 assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24201 assert_eq!(
24202 monitoring_claude.capabilities.adapters.history,
24203 standard_claude.capabilities.adapters.history,
24204 );
24205 assert_ne!(monitoring_claude.launch, standard_claude.launch);
24206 let codex = AgentId::new("codex").unwrap();
24207 let standard_codex = standard.get(&codex).unwrap();
24208 let monitoring_codex = monitoring.get(&codex).unwrap();
24209 assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24210 assert_eq!(
24211 monitoring_codex.capabilities.adapters.history,
24212 standard_codex.capabilities.adapters.history,
24213 );
24214 assert_ne!(monitoring_codex.launch, standard_codex.launch);
24215 let codex_validation = monitoring_codex.launch.fixed_args.iter()
24216 .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24217 .unwrap();
24218 for required in [
24219 "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24220 "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24221 "@('role', 'text')",
24222 "g4a-private-provider-session-canary",
24223 "private-provider-login-identity",
24224 "$rawProof",
24225 "$env:GATE4AGENT_HOOK_URL",
24226 "g4a-private-provider-session-canary",
24227 ] {
24228 assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24229 }
24230 for provider in ["grok", "kimi"] {
24231 let provider = AgentId::new(provider).unwrap();
24232 assert_eq!(monitoring.get(&provider), standard.get(&provider));
24233 }
24234
24235 let config = NodeServerConfig::new(
24236 format!(
24237 r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24238 std::process::id(),
24239 ),
24240 "fixture-token",
24241 NodeId::new("monitoring-context-fixture-node").unwrap(),
24242 [WorkspaceConfig::new(
24243 WorkspaceId::new("primary").unwrap(),
24244 std::env::current_dir().unwrap(),
24245 )
24246 .unwrap()],
24247 )
24248 .unwrap();
24249 let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24250 assert!(server.shared.fixture_semantic_hook_policy);
24251 drop(server);
24252 std::fs::remove_dir_all(root).unwrap();
24253 }
24254
24255 #[cfg(feature = "fixture")]
24256 #[test]
24257 fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24258 let root = temporary_workspace_root("kimi-context-only-fixture");
24259 std::fs::create_dir_all(&root).unwrap();
24260 let proof_path = root.join("context.proof");
24261 let standard = NodeServer::context_pack_fixture_catalog(
24262 proof_path.clone(),
24263 None,
24264 false,
24265 ).unwrap();
24266 let kimi = AgentId::new("kimi").unwrap();
24267 let context_only = NodeServer::context_pack_fixture_catalog(
24268 proof_path,
24269 Some(&kimi),
24270 false,
24271 )
24272 .unwrap();
24273
24274 let codex = AgentId::new("codex").unwrap();
24275 assert_eq!(
24276 standard.get(&codex).unwrap().launch.fixed_args,
24277 context_only.get(&codex).unwrap().launch.fixed_args,
24278 );
24279 let script = context_only
24280 .get(&kimi)
24281 .unwrap()
24282 .launch
24283 .fixed_args
24284 .iter()
24285 .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24286 .expect("Kimi context-only validation script is missing");
24287 for required in [
24288 "$bundleArgs.Count -ne 0",
24289 "GATE4AGENT_CONTEXT_ROOT",
24290 "context-pack.json",
24291 "$entries.Count -ne 1",
24292 "g4a-context-pack-v1",
24293 "$document.PSObject.Properties['cwd']",
24294 "'claude', 'codex', 'grok', 'kimi'",
24295 "$hasUser",
24296 "$hasAssistant",
24297 "SHA256",
24298 "context-only",
24299 ] {
24300 assert!(script.contains(required), "missing validation: {required}");
24301 }
24302 for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24303 assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24304 }
24305 assert!(!standard
24306 .get(&kimi)
24307 .unwrap()
24308 .launch
24309 .fixed_args
24310 .iter()
24311 .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24312
24313 std::fs::remove_dir_all(root).unwrap();
24314 }
24315
24316 #[cfg(all(feature = "fixture", windows))]
24317 #[tokio::test]
24318 async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24319 let root = temporary_workspace_root("kimi-context-only-spawn");
24320 let workspace_root = root.join("workspace");
24321 std::fs::create_dir_all(&workspace_root).unwrap();
24322 let materialization_root = root.join("private-materializations");
24323 let proof_path = root.join("context.proof");
24324 let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24325 let workspace_id = WorkspaceId::new("primary").unwrap();
24326 let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24327 let endpoint = format!(
24328 r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24329 std::process::id(),
24330 unix_time_ms(),
24331 );
24332 let spawn_profiles = SpawnProfileRegistry::new([
24333 crate::protocol::SpawnProfileDefaults {
24334 profile_id: profile_id.clone(),
24335 revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24336 provider: agent("kimi"),
24337 mode: SessionMode::Pty,
24338 terminal_size: gate4agent_types::TerminalSize {
24339 rows: 30,
24340 columns: 120,
24341 },
24342 prompt: None,
24343 bundle_id: None,
24344 context_id: None,
24345 environment_profile_id: None,
24346 },
24347 ])
24348 .unwrap();
24349 let mut config = NodeServerConfig::new(
24350 &endpoint,
24351 "fixture-token",
24352 node_id.clone(),
24353 [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24354 )
24355 .unwrap()
24356 .with_spawn_profiles(spawn_profiles)
24357 .with_session_environment_materialization(
24358 materialization_root,
24359 Arc::new(FixtureSecretResolver {
24360 deny: Arc::new(AtomicBool::new(false)),
24361 }),
24362 )
24363 .unwrap();
24364 config.fixture_raw_pty_runtime = true;
24365 let server = NodeServer::new_context_only_proof_fixture(
24366 config,
24367 agent("kimi"),
24368 proof_path.clone(),
24369 )
24370 .unwrap();
24371 let shared = Arc::clone(&server.shared);
24372 let shutdown = server.shutdown_handle();
24373
24374 let history = HistorySessionRecord {
24375 session_id: "context-only-source-history".to_owned(),
24376 title: None,
24377 cwd: None,
24378 model: None,
24379 message_count: 2,
24380 completed_turn_count: None,
24381 total_tokens: 0,
24382 messages: vec![
24383 gate4agent_types::HistoryMessageRecord {
24384 role: gate4agent_types::HistoryMessageRole::User,
24385 text: "continue the bounded parent run through native C2".to_owned(),
24386 },
24387 gate4agent_types::HistoryMessageRecord {
24388 role: gate4agent_types::HistoryMessageRole::Assistant,
24389 text: "the exact parent context is ready for the child run".to_owned(),
24390 },
24391 ],
24392 };
24393 let pack = NodeContextPack::export(
24394 ContextPackLineageReceipt {
24395 source_node_id: node_id.clone(),
24396 source_session: SessionAddress {
24397 workspace_id: workspace_id.clone(),
24398 session: SessionKey {
24399 instance_id: AgentInstanceId(71),
24400 generation: SessionGeneration(1),
24401 },
24402 },
24403 source_provider: agent("codex"),
24404 },
24405 &history,
24406 )
24407 .unwrap();
24408 let expected_context_hash = digest(&SHA256, pack.bytes())
24409 .as_ref()
24410 .iter()
24411 .map(|byte| format!("{byte:02x}"))
24412 .collect::<String>();
24413 let context = shared
24414 .context_catalog
24415 .write()
24416 .unwrap_or_else(|poisoned| poisoned.into_inner())
24417 .insert(pack)
24418 .unwrap();
24419 let spec = SpawnSpec {
24420 target: crate::protocol::SpawnTarget {
24421 node_id,
24422 workspace_id: workspace_id.clone(),
24423 worktree_id: None,
24424 },
24425 profile_id,
24426 expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24427 "target-kimi-r1",
24428 ).unwrap(),
24429 overrides: crate::protocol::SpawnOverrides {
24430 context_id: crate::protocol::SpawnOverride::Set {
24431 value: context.id.clone(),
24432 },
24433 ..crate::protocol::SpawnOverrides::default()
24434 },
24435 deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24436 idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24437 required_capabilities: SpawnRequiredCapabilities::default(),
24438 };
24439 let resolved = match shared.resolve_spawn_spec(&spec) {
24440 Ok(resolved) => resolved,
24441 Err(error) => panic!(
24442 "Kimi context-only resolve rejected: exact_code={:?} message={}",
24443 error.code,
24444 error.message,
24445 ),
24446 };
24447 let resolved_context = match shared.resolve_context(&resolved) {
24448 Ok(Some(resolved_context)) => resolved_context,
24449 Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24450 Err(error) => panic!(
24451 "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24452 error.code,
24453 error.message,
24454 ),
24455 };
24456 assert_eq!(resolved_context, context);
24457 let runtime_policy = match shared
24458 .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24459 .await
24460 {
24461 Ok(runtime_policy) => runtime_policy,
24462 Err(error) => panic!(
24463 "Kimi RawPty admission rejected: exact_code={:?} message={}",
24464 error.code,
24465 error.message,
24466 ),
24467 };
24468 assert!(runtime_policy.raw_pty_lifecycle);
24469
24470 let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24471 let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24472 agent("kimi"),
24473 TransportKind::Pty,
24474 vec![EnvMutation {
24475 key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24476 value: Some(OsString::from("must-remain-rejected")),
24477 }],
24478 )
24479 .unwrap();
24480 assert_eq!(
24481 launch_control
24482 .install_native_launch_environment_overlay(
24483 AgentInstanceId(72),
24484 generic_overlay,
24485 )
24486 .unwrap_err(),
24487 gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24488 );
24489
24490 let diagnostic_address = SessionAddress {
24491 workspace_id: workspace_id.clone(),
24492 session: SessionKey {
24493 instance_id: AgentInstanceId(73),
24494 generation: SessionGeneration(1),
24495 },
24496 };
24497 let (diagnostic_overlay, diagnostic_materialization) = match shared
24498 .prepare_session_materialization(
24499 &diagnostic_address,
24500 &agent("kimi"),
24501 SessionMode::Pty,
24502 None,
24503 None,
24504 Some(&resolved_context),
24505 None,
24506 )
24507 {
24508 Ok(Some(prepared)) => prepared,
24509 Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24510 Err(error) => panic!(
24511 "Kimi context-only materialization rejected: exact_code={:?} message={}",
24512 error.code,
24513 error.message,
24514 ),
24515 };
24516 let diagnostic_overlay = diagnostic_overlay
24517 .expect("Kimi context-only materialization omitted its environment overlay");
24518 assert!(matches!(
24519 &diagnostic_overlay,
24520 PreparedNativeLaunchOverlay::Environment(_),
24521 ));
24522 match shared.install_prepared_launch_overlay(
24523 diagnostic_address.session.instance_id,
24524 diagnostic_overlay,
24525 ) {
24526 Ok(None) => {}
24527 Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24528 Err(error) => panic!(
24529 "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24530 error.code,
24531 error.message,
24532 ),
24533 }
24534 assert!(launch_control.clear_native_instance_launch_overlay(
24535 diagnostic_address.session.instance_id,
24536 ));
24537 drop(diagnostic_materialization);
24538 assert!(shared
24539 .materializations
24540 .lock()
24541 .unwrap_or_else(|poisoned| poisoned.into_inner())
24542 .is_empty());
24543
24544 let node_task = tokio::spawn(server.run());
24545 let ready_client = timeout(Duration::from_secs(5), async {
24546 loop {
24547 match gate4agent_node_wire::LocalNodeClient::connect(
24548 &endpoint,
24549 &shared.node_id,
24550 ClientRole::Observer,
24551 "fixture-token",
24552 )
24553 .await
24554 {
24555 Ok(client) => break client,
24556 Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24557 Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24558 }
24559 }
24560 })
24561 .await
24562 .expect("Kimi context-only Node did not become ready");
24563 assert_eq!(
24564 ready_client
24565 .hello()
24566 .snapshot
24567 .provider_runtime_statuses
24568 .iter()
24569 .find(|status| status.provider().as_str() == "kimi")
24570 .expect("Kimi runtime status is missing")
24571 .mode(),
24572 crate::protocol::ProviderRuntimeMode::RawPassthrough,
24573 );
24574 drop(ready_client);
24575
24576 let receipt = match shared.spawn_from_spec(spec).await {
24577 Ok(receipt) => receipt,
24578 Err(error) => panic!(
24579 "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
24580 error.code,
24581 error.message,
24582 ),
24583 };
24584 assert_eq!(receipt.provider, agent("kimi"));
24585 assert_eq!(receipt.context.as_ref(), Some(&context));
24586 assert!(receipt.context_binding_is_valid());
24587 timeout(Duration::from_secs(5), async {
24588 while !proof_path.is_file() {
24589 sleep(Duration::from_millis(10)).await;
24590 }
24591 })
24592 .await
24593 .expect("Kimi context-only fixture did not write its proof");
24594
24595 let proof = std::fs::read_to_string(&proof_path).unwrap();
24596 let lines = proof.lines().collect::<Vec<_>>();
24597 assert_eq!(lines.len(), 7);
24598 assert_eq!(lines[0], "context-only");
24599 assert!(Path::new(lines[1]).is_absolute());
24600 assert_ne!(
24601 std::fs::canonicalize(Path::new(lines[1])).unwrap(),
24602 std::fs::canonicalize(&workspace_root).unwrap(),
24603 );
24604 assert_eq!(
24605 std::fs::canonicalize(Path::new(lines[2])).unwrap(),
24606 std::fs::canonicalize(&workspace_root).unwrap(),
24607 );
24608 assert_eq!(lines[3], expected_context_hash);
24609 assert_eq!(lines[4], "g4a-context-pack-v1");
24610 assert_eq!(lines[5], "codex");
24611 assert_eq!(lines[6], "2");
24612 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
24613 session.instance_id == receipt.session.session.instance_id
24614 && session.agent_id.as_str() == "kimi"
24615 }));
24616
24617 shutdown.request_shutdown().await.unwrap();
24618 timeout(Duration::from_secs(5), node_task)
24619 .await
24620 .unwrap()
24621 .unwrap()
24622 .unwrap();
24623 drop(shutdown);
24624 drop(shared);
24625 std::fs::remove_dir_all(root).unwrap();
24626 }
24627
24628 #[cfg(feature = "fixture")]
24629 #[test]
24630 fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
24631 let root = temporary_workspace_root("clean-exit-constructor");
24632 std::fs::create_dir_all(&root).unwrap();
24633 let config = construction_test_config("clean-exit-constructor")
24634 .with_state_path(root.join("node-state.json"))
24635 .unwrap();
24636 let server = NodeServer::new_clean_exit_fixture(
24637 config,
24638 root.clone(),
24639 root.join("started.marker"),
24640 root.join("release.signal"),
24641 )
24642 .unwrap();
24643 assert_eq!(
24644 server
24645 .shared
24646 .enabled_providers
24647 .iter()
24648 .map(AgentId::as_str)
24649 .collect::<Vec<_>>(),
24650 ["claude"],
24651 );
24652 drop(server);
24653 std::fs::remove_dir_all(root).unwrap();
24654 }
24655
24656 #[test]
24657 fn production_registry_provider_contract_manifest_is_exact() {
24658 let registry = active_registry().unwrap();
24659 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24660 assert_eq!(
24661 providers
24662 .iter()
24663 .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
24664 .collect::<Vec<_>>(),
24665 vec![
24666 ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24667 ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24668 ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24669 ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
24670 ]
24671 );
24672 assert_eq!(
24673 adapters
24674 .iter()
24675 .map(|contract| (
24676 contract.provider.as_str(),
24677 contract.family,
24678 contract.adapter_id.as_str(),
24679 contract.revision.as_str(),
24680 ))
24681 .collect::<Vec<_>>(),
24682 vec![
24683 ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
24684 ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
24685 ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
24686 ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
24687 ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
24688 ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
24689 ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
24690 ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
24691 ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
24692 ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
24693 ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
24694 ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
24695 ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
24696 ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
24697 ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
24698 ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
24699 ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
24700 ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
24701 ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
24702 ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
24703 ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
24704 ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
24705 ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
24706 ]
24707 );
24708 }
24709
24710 fn construction_test_config(name: &str) -> NodeServerConfig {
24711 let workspace = WorkspaceConfig::new(
24712 WorkspaceId::new("primary").unwrap(),
24713 std::env::current_dir().unwrap(),
24714 )
24715 .unwrap();
24716 NodeServerConfig::new(
24717 format!(r"\\.\pipe\gate4agent-{name}"),
24718 "fixture-token",
24719 NodeId::new(name).unwrap(),
24720 [workspace],
24721 )
24722 .unwrap()
24723 }
24724
24725 fn maximum_size_provider_manifest_registry() -> AgentRegistry {
24726 let adapter = AdapterBinding::new(
24727 gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
24728 "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
24729 gate4agent_types::AdapterVerification::SyntheticFixture,
24730 )
24731 .unwrap();
24732 let agent_ids = ["claude", "codex", "kimi"]
24733 .into_iter()
24734 .map(|agent_id| AgentId::new(agent_id).unwrap())
24735 .collect::<Vec<_>>();
24736 let adapters = gate4agent_catalog::AdapterRegistry::new(
24737 [
24738 AdapterFamily::PtySemantic,
24739 AdapterFamily::Pipe,
24740 AdapterFamily::OneShot,
24741 AdapterFamily::Acp,
24742 AdapterFamily::Hook,
24743 AdapterFamily::ManagedHook,
24744 AdapterFamily::History,
24745 AdapterFamily::Resume,
24746 AdapterFamily::SessionOptions,
24747 AdapterFamily::CapabilityProbe,
24748 ]
24749 .into_iter()
24750 .map(|family| gate4agent_catalog::AdapterDescriptor {
24751 family,
24752 binding: adapter.clone(),
24753 agents: agent_ids.clone(),
24754 }),
24755 )
24756 .unwrap();
24757 let specs = agent_ids
24758 .iter()
24759 .map(|agent_id| {
24760 let agent_id = agent_id.as_str();
24761 let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
24762 spec.revision =
24763 "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
24764 spec.capabilities.transports.pty_adapter = Some(adapter.clone());
24765 spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
24766 adapter: adapter.clone(),
24767 protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
24768 launch_override: None,
24769 prompt_delivery: gate4agent_types::PipePromptDelivery::None,
24770 });
24771 spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
24772 adapter: adapter.clone(),
24773 launch_override: None,
24774 });
24775 spec.capabilities.adapters.hook = Some(adapter.clone());
24776 spec.capabilities.adapters.managed_hook = Some(adapter.clone());
24777 spec.capabilities.adapters.one_shot = Some(adapter.clone());
24778 spec.capabilities.adapters.history = Some(adapter.clone());
24779 spec.capabilities.adapters.resume = Some(adapter.clone());
24780 spec.capabilities.adapters.session_options = Some(adapter.clone());
24781 spec.capabilities.adapters.capability_probe = Some(adapter.clone());
24782 spec
24783 })
24784 .collect::<Vec<_>>();
24785 AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
24786 }
24787
24788 #[test]
24796 fn node_server_construction_accepts_the_production_manifest() {
24797 let registry = active_registry().unwrap();
24798 let (providers, _) = provider_contract_manifest(®istry).unwrap();
24799 assert_eq!(providers.len(), registry.iter().count());
24800 assert!(!providers.is_empty());
24801 let server = NodeServer::new_with_registry(
24802 construction_test_config("manifest-production-bound"),
24803 registry,
24804 )
24805 .unwrap();
24806 drop(server);
24807 }
24808
24809 #[test]
24810 fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
24811 let registry = maximum_size_provider_manifest_registry();
24812 let (providers, adapters) = provider_contract_manifest(®istry).unwrap();
24813 assert_eq!(providers.len(), 3);
24814 assert_eq!(adapters.len(), 30);
24815 assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
24816 let error = match NodeServer::new_with_registry(
24817 construction_test_config("manifest-handshake-overflow"),
24818 registry,
24819 ) {
24820 Ok(_) => panic!("oversized negotiated manifest was accepted"),
24821 Err(error) => error,
24822 };
24823 assert!(matches!(
24824 error,
24825 NodeServerError::ProviderContractManifest(message)
24826 if message.contains("authentication binding length")
24827 && message.contains("8192-byte limit")
24828 ));
24829 }
24830
24831 #[test]
24832 fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
24833 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24834 spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
24835 let registry = AgentRegistry::new([spec]).unwrap();
24836 assert!(matches!(
24837 provider_contract_manifest(®istry),
24838 Err(NodeServerError::ProviderContractManifest(message))
24839 if message.contains("exceeds the 128-byte limit")
24840 ));
24841 }
24842
24843 #[test]
24844 fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
24845 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24846 spec.revision = "invalid revision".to_owned();
24847 let registry = AgentRegistry::new([spec]).unwrap();
24848 assert!(matches!(
24849 provider_contract_manifest(®istry),
24850 Err(NodeServerError::ProviderContractManifest(message))
24851 if message.contains("bounded lowercase ASCII")
24852 ));
24853 }
24854
24855 #[test]
24856 fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
24857 let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
24858 let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
24859 let conflicting_binding = builtin_registry()
24860 .get_by_id("codex")
24861 .unwrap()
24862 .capabilities
24863 .adapters
24864 .one_shot
24865 .clone()
24866 .unwrap();
24867 let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
24868 pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
24869 pipe.adapter = transport_binding;
24870 spec.capabilities.adapters.one_shot = Some(conflicting_binding);
24871 let registry = AgentRegistry::new([spec]).unwrap();
24872 assert!(matches!(
24873 provider_contract_manifest(®istry),
24874 Err(NodeServerError::ProviderContractManifest(message))
24875 if message.contains("conflicting OneShot adapter bindings")
24876 ));
24877 }
24878
24879 #[test]
24880 fn public_node_failures_expose_only_fixed_categories() {
24881 let provider = failure(
24882 NodeFailureCode::BackendOperationFailed,
24883 r"provider bearer-secret failed at C:\private\session.jsonl",
24884 );
24885 assert_eq!(provider.message, "backend-operation-failed");
24886 assert!(!provider.message.contains("bearer-secret"));
24887 assert!(!provider.message.contains(r"C:\private"));
24888 let persistence = persistence_failure(io::Error::new(
24889 io::ErrorKind::Other,
24890 r"state write failed at C:\private\state-v1.json",
24891 ));
24892 assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
24893 assert!(!persistence.message.contains(r"C:\private"));
24894
24895 for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
24896 let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
24897 assert_eq!(
24898 generic.to_string(),
24899 "node durable state failed: durable-state-load-failed",
24900 );
24901 }
24902 }
24903
24904 #[test]
24905 fn terminal_byte_sequences_are_bounded_before_dispatch() {
24906 let sequence = b"\x1b[1;5D".to_vec();
24907 assert_eq!(
24908 terminal_bytes_action(sequence.clone()).unwrap(),
24909 InputAction::TerminalBytes(sequence),
24910 );
24911 assert_eq!(
24912 terminal_bytes_action(Vec::new()).unwrap_err().code,
24913 NodeFailureCode::InvalidRequest,
24914 );
24915 assert_eq!(
24916 terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
24917 .unwrap_err()
24918 .code,
24919 NodeFailureCode::InvalidRequest,
24920 );
24921 }
24922
24923 #[test]
24924 fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
24925 let root = temporary_workspace_root("exclusive-state-owner");
24926 std::fs::create_dir_all(&root).unwrap();
24927 let workspace = WorkspaceConfig::new(
24928 WorkspaceId::new("primary").unwrap(),
24929 &root,
24930 )
24931 .unwrap();
24932 let state_path = root.join("state-v1.json");
24933 let endpoint = format!(
24934 r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
24935 std::process::id(),
24936 std::time::SystemTime::now()
24937 .duration_since(std::time::UNIX_EPOCH)
24938 .unwrap()
24939 .as_nanos(),
24940 );
24941 let config = NodeServerConfig::new(
24942 endpoint,
24943 "fixture-token",
24944 NodeId::new("node-1").unwrap(),
24945 [workspace],
24946 )
24947 .unwrap()
24948 .with_state_path(&state_path)
24949 .unwrap();
24950
24951 let first = NodeServer::new(config.clone()).unwrap();
24952 let error = match NodeServer::new(config.clone()) {
24953 Ok(_) => panic!("a second node server acquired the same durable state path"),
24954 Err(error) => error,
24955 };
24956 assert!(matches!(
24957 &error,
24958 NodeServerError::DurableState(error)
24959 if error.kind() == io::ErrorKind::WouldBlock
24960 ));
24961 assert_eq!(
24962 error.to_string(),
24963 "node durable state failed: durable-state-lock-failed",
24964 );
24965 assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
24966 drop(first);
24967 let second = NodeServer::new(config).unwrap();
24968 drop(second);
24969 std::fs::remove_dir_all(root).unwrap();
24970 }
24971
24972 #[test]
24973 fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
24974 let catalog = active_registry().unwrap();
24975 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24976 let workspace_id = WorkspaceId::new("primary").unwrap();
24977 let workspace = WorkspaceConfig::new(
24978 workspace_id.clone(),
24979 std::env::current_dir().unwrap(),
24980 )
24981 .unwrap();
24982 let shared = NodeShared::new(
24983 handle,
24984 "fixture-token".to_owned(),
24985 NodeId::new("node-1").unwrap(),
24986 vec![workspace.clone()],
24987 vec![agent("claude"), agent("codex"), agent("kimi")],
24988 );
24989 let instance_id = AgentInstanceId(17);
24990 let generation = SessionGeneration(3);
24991 assert!(shared.address_for(instance_id, generation).is_none());
24992 shared.bind_session(instance_id, workspace_id.clone(), generation);
24993 let address = SessionAddress {
24994 workspace_id,
24995 session: SessionKey { instance_id, generation },
24996 };
24997 assert_eq!(
24998 shared.workspace_root(&address.workspace_id).unwrap(),
24999 workspace.canonical_root,
25000 );
25001 assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25002 shared.remove_binding(&address);
25003 assert!(shared.address_for(instance_id, generation).is_none());
25004 }
25005
25006 #[test]
25007 fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25008 let address = SessionAddress {
25009 workspace_id: WorkspaceId::new("primary").unwrap(),
25010 session: SessionKey {
25011 instance_id: AgentInstanceId(7),
25012 generation: SessionGeneration(2),
25013 },
25014 };
25015 let record_id = SessionRecordId::new("record-a").unwrap();
25016 let size = gate4agent_types::TerminalSize {
25017 rows: 24,
25018 columns: 80,
25019 };
25020 let address_requests = [
25021 NodeRequest::Resume {
25022 session: address.clone(),
25023 terminal_size: size,
25024 initial_prompt: None,
25025 },
25026 NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25027 NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25028 NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25029 NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25030 NodeRequest::TerminalControl {
25031 session: address.clone(),
25032 control: TerminalControl::Enter,
25033 },
25034 NodeRequest::Resize { session: address.clone(), size },
25035 NodeRequest::Interrupt { session: address.clone() },
25036 NodeRequest::Stop { session: address.clone(), force: false },
25037 NodeRequest::Remove { session: address },
25038 ];
25039 for request in &address_requests {
25040 assert!(request_requires_open_provider_ids_with(
25041 request,
25042 |_| Some(agent("third-party-agent")),
25043 |_| Some(agent("claude")),
25044 ));
25045 assert!(!request_requires_open_provider_ids_with(
25046 request,
25047 |_| Some(agent("claude")),
25048 |_| Some(agent("claude")),
25049 ));
25050 assert!(request_requires_open_provider_ids_with(
25051 request,
25052 |_| None,
25053 |_| Some(agent("claude")),
25054 ));
25055 }
25056
25057 let record_requests = [
25058 NodeRequest::RenameSessionRecord {
25059 record_id: record_id.clone(),
25060 display_name: "renamed".to_owned(),
25061 },
25062 NodeRequest::ResumeSessionRecord {
25063 record_id: record_id.clone(),
25064 terminal_size: size,
25065 initial_prompt: None,
25066 },
25067 NodeRequest::ForgetSessionRecord { record_id },
25068 ];
25069 for request in &record_requests {
25070 assert!(request_requires_open_provider_ids_with(
25071 request,
25072 |_| Some(agent("claude")),
25073 |_| Some(agent("grok")),
25074 ));
25075 assert!(!request_requires_open_provider_ids_with(
25076 request,
25077 |_| Some(agent("claude")),
25078 |_| Some(agent("kimi")),
25079 ));
25080 assert!(request_requires_open_provider_ids_with(
25081 request,
25082 |_| Some(agent("claude")),
25083 |_| None,
25084 ));
25085 }
25086
25087 assert!(request_requires_open_provider_ids_with(
25088 &NodeRequest::Spawn {
25089 workspace_id: WorkspaceId::new("primary").unwrap(),
25090 provider: agent("third-party-agent"),
25091 mode: SessionMode::Pty,
25092 terminal_size: size,
25093 initial_prompt: None,
25094 },
25095 |_| None,
25096 |_| None,
25097 ));
25098 assert!(!request_requires_open_provider_ids_with(
25099 &NodeRequest::Snapshot,
25100 |_| None,
25101 |_| None,
25102 ));
25103 assert!(request_requires_open_provider_ids_with(
25104 &NodeRequest::ForgetContextPack {
25105 context_id: SpawnContextId::new("ctx-forget").unwrap(),
25106 },
25107 |_| None,
25108 |_| None,
25109 ));
25110 }
25111
25112 #[test]
25113 fn legacy_projection_filters_open_record_replies_and_event_history() {
25114 let catalog = active_registry().unwrap();
25115 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25116 let workspace = WorkspaceConfig::new(
25117 WorkspaceId::new("primary").unwrap(),
25118 std::env::current_dir().unwrap(),
25119 )
25120 .unwrap();
25121 let shared = NodeShared::new(
25122 handle,
25123 "fixture-token".to_owned(),
25124 NodeId::new("node-1").unwrap(),
25125 vec![workspace],
25126 vec![agent("claude"), agent("grok")],
25127 );
25128 let legacy = record("claude", "legacy-record");
25129 let open = record("grok", "open-record");
25130 let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25131 record: legacy.clone(),
25132 });
25133 let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25134 record: open.clone(),
25135 });
25136 let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25137 record_id: legacy.record_id.clone(),
25138 });
25139 let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25140 record_id: open.record_id.clone(),
25141 });
25142 assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25143 assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25144 assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25145 assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25146
25147 let mut record_reply = ResponseEnvelope {
25148 request_id: 1,
25149 result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25150 };
25151 project_response_legacy_provider_ids(&shared, &mut record_reply);
25152 assert!(matches!(
25153 record_reply.result,
25154 Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25155 ));
25156
25157 let mut resync = ResponseEnvelope {
25158 request_id: 2,
25159 result: Ok(NodeResponse::Resync {
25160 event_sequence: open_removed.sequence,
25161 oldest_available_sequence: 1,
25162 snapshot: shared.snapshot(),
25163 events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25164 }),
25165 };
25166 project_response_legacy_provider_ids(&shared, &mut resync);
25167 let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25168 panic!("legacy projection changed the resync response kind");
25169 };
25170 assert!(snapshot.session_records.iter().all(|item| {
25171 provider_id_is_legacy(&item.provider)
25172 }));
25173 assert_eq!(events.len(), 2);
25174 assert!(events.iter().all(|event| matches!(
25175 &event.event,
25176 NodeEvent::SessionRecordUpserted { record }
25177 if provider_id_is_legacy(&record.provider)
25178 ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25179 }
25180
25181 #[test]
25182 fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25183 let catalog = active_registry().unwrap();
25184 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25185 let workspace_id = WorkspaceId::new("primary").unwrap();
25186 let workspace = WorkspaceConfig::new(
25187 workspace_id.clone(),
25188 std::env::current_dir().unwrap(),
25189 )
25190 .unwrap();
25191 let shared = NodeShared::new(
25192 handle,
25193 "fixture-token".to_owned(),
25194 NodeId::new("node-1").unwrap(),
25195 vec![workspace],
25196 vec![agent("claude")],
25197 );
25198 for index in 0..CONTROL_SESSIONS_MAX {
25199 shared.bind_session(
25200 AgentInstanceId((index + 1) as u64),
25201 workspace_id.clone(),
25202 SessionGeneration(1),
25203 );
25204 }
25205 let error = shared.ensure_binding_capacity().unwrap_err();
25206 assert_eq!(error.code, NodeFailureCode::BackendBusy);
25207 }
25208
25209 #[tokio::test]
25210 async fn provider_spawn_admission_rejects_before_session_mutation() {
25211 let catalog = active_registry().unwrap();
25212 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25213 let workspace = WorkspaceConfig::new(
25214 WorkspaceId::new("primary").unwrap(),
25215 std::env::current_dir().unwrap(),
25216 )
25217 .unwrap();
25218 let mut shared = NodeShared::new(
25219 handle,
25220 "fixture-token".to_owned(),
25221 NodeId::new("node-provider-admission").unwrap(),
25222 vec![workspace],
25223 vec![agent("claude")],
25224 );
25225 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25226 crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25227 ])
25228 .unwrap();
25229 let terminal_size = gate4agent_types::TerminalSize {
25230 rows: 24,
25231 columns: 80,
25232 };
25233
25234 let unavailable = shared
25235 .spawn_session(
25236 WorkspaceId::new("primary").unwrap(),
25237 agent("claude"),
25238 SessionMode::Pty,
25239 terminal_size,
25240 None,
25241 )
25242 .await
25243 .unwrap_err();
25244 assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25245 assert_eq!(unavailable.message, "backend-operation-failed");
25246
25247 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25248 crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25249 agent("claude"),
25250 Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25251 ),
25252 ])
25253 .unwrap();
25254 let semantic = shared
25255 .spawn_session(
25256 WorkspaceId::new("primary").unwrap(),
25257 agent("claude"),
25258 SessionMode::Inline,
25259 terminal_size,
25260 None,
25261 )
25262 .await
25263 .unwrap_err();
25264 assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25265 assert_eq!(semantic.message, "unsupported-capability");
25266
25267 assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25268 assert!(shared
25269 .session_bindings
25270 .lock()
25271 .unwrap_or_else(|poisoned| poisoned.into_inner())
25272 .is_empty());
25273 assert!(shared
25274 .session_records
25275 .lock()
25276 .unwrap_or_else(|poisoned| poisoned.into_inner())
25277 .records
25278 .is_empty());
25279 assert!(shared.handle.snapshot().sessions.is_empty());
25280 }
25281
25282 #[tokio::test]
25283 async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25284 let catalog = active_registry().unwrap();
25285 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25286 let workspace_id = WorkspaceId::new("primary").unwrap();
25287 let workspace = WorkspaceConfig::new(
25288 workspace_id.clone(),
25289 std::env::current_dir().unwrap(),
25290 )
25291 .unwrap();
25292 let shared = NodeShared::new(
25293 handle,
25294 "fixture-token".to_owned(),
25295 NodeId::new("node-raw-spawn-record").unwrap(),
25296 vec![workspace],
25297 vec![agent("claude")],
25298 );
25299 let address = SessionAddress {
25300 workspace_id,
25301 session: SessionKey {
25302 instance_id: AgentInstanceId(71),
25303 generation: SessionGeneration::default(),
25304 },
25305 };
25306
25307 let record_id = shared
25308 .bind_spawn_session(
25309 &address,
25310 agent("claude"),
25311 SessionMode::Pty,
25312 ProviderRuntimePolicy::raw_pty(),
25313 SpawnRecordPolicy::Always,
25314 None,
25315 )
25316 .unwrap()
25317 .unwrap();
25318 shared
25319 .handle
25320 .dispatch(shared.prepare_command(ControlCommand::Register {
25321 instance_id: address.session.instance_id,
25322 agent_id: agent("claude"),
25323 transport: TransportKind::Pty,
25324 }))
25325 .unwrap();
25326 runtime.tick().await;
25327
25328 assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25329 session.instance_id == address.session.instance_id
25330 && session.generation == address.session.generation
25331 }));
25332 let record = shared.record(&record_id).unwrap();
25333 assert_eq!(record.display_name, "claude #1");
25334 assert_eq!(record.state, ManagedSessionState::Live);
25335 assert_eq!(record.active_session.as_ref(), Some(&address));
25336 assert!(record.provider_session.is_none());
25337 let binding_record_id = shared
25338 .session_bindings
25339 .lock()
25340 .unwrap_or_else(|poisoned| poisoned.into_inner())
25341 .get(&address.session.instance_id)
25342 .and_then(|binding| binding.record_id.clone());
25343 assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25344
25345 let renamed = shared
25346 .rename_session_record(&record_id, "raw review".to_owned())
25347 .unwrap();
25348 assert_eq!(renamed.record_id, record_id);
25349 assert_eq!(renamed.display_name, "raw review");
25350 shared.reconcile_managed_record(
25351 &record_id,
25352 &address,
25353 &ControlEvent {
25354 sequence: 1,
25355 command_id: None,
25356 instance_id: address.session.instance_id,
25357 generation: address.session.generation,
25358 event: ControlEventKind::Running { process_id: None },
25359 },
25360 false,
25361 false,
25362 );
25363 let running = shared.record(&record_id).unwrap();
25364 assert_eq!(running.state, ManagedSessionState::Live);
25365 assert_eq!(running.active_session.as_ref(), Some(&address));
25366 assert_eq!(running.display_name, "raw review");
25367
25368 shared.reconcile_managed_record(
25369 &record_id,
25370 &address,
25371 &ControlEvent {
25372 sequence: 2,
25373 command_id: None,
25374 instance_id: address.session.instance_id,
25375 generation: address.session.generation,
25376 event: ControlEventKind::Exited {
25377 exit_code: Some(0),
25378 forced: false,
25379 },
25380 },
25381 false,
25382 false,
25383 );
25384 let detached = shared.record(&record_id).unwrap();
25385 assert_eq!(detached.state, ManagedSessionState::Unavailable);
25386 assert!(detached.active_session.is_none());
25387 assert_eq!(detached.display_name, "raw review");
25388 let renamed_detached = shared
25389 .rename_session_record(&record_id, "raw review detached".to_owned())
25390 .unwrap();
25391 assert_eq!(renamed_detached.record_id, record_id);
25392 assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25393 assert!(renamed_detached.active_session.is_none());
25394 assert_eq!(renamed_detached.display_name, "raw review detached");
25395 assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25396 }
25397
25398 #[test]
25399 fn legacy_raw_spawn_binding_remains_untracked() {
25400 let catalog = active_registry().unwrap();
25401 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25402 let workspace_id = WorkspaceId::new("primary").unwrap();
25403 let workspace = WorkspaceConfig::new(
25404 workspace_id.clone(),
25405 std::env::current_dir().unwrap(),
25406 )
25407 .unwrap();
25408 let shared = NodeShared::new(
25409 handle,
25410 "fixture-token".to_owned(),
25411 NodeId::new("node-legacy-raw-spawn").unwrap(),
25412 vec![workspace],
25413 vec![agent("claude")],
25414 );
25415 let address = SessionAddress {
25416 workspace_id,
25417 session: SessionKey {
25418 instance_id: AgentInstanceId(72),
25419 generation: SessionGeneration::default(),
25420 },
25421 };
25422
25423 let record_id = shared
25424 .bind_spawn_session(
25425 &address,
25426 agent("claude"),
25427 SessionMode::Pty,
25428 ProviderRuntimePolicy::raw_pty(),
25429 SpawnRecordPolicy::ProviderIdentityOnly,
25430 None,
25431 )
25432 .unwrap();
25433
25434 assert!(record_id.is_none());
25435 let binding_record_id = shared
25436 .session_bindings
25437 .lock()
25438 .unwrap_or_else(|poisoned| poisoned.into_inner())
25439 .get(&address.session.instance_id)
25440 .and_then(|binding| binding.record_id.clone());
25441 assert!(binding_record_id.is_none());
25442 assert!(shared.snapshot().session_records.is_empty());
25443 }
25444
25445 #[tokio::test]
25446 async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25447 let catalog = active_registry().unwrap();
25448 let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25449 let workspace_id = WorkspaceId::new("primary").unwrap();
25450 let workspace = WorkspaceConfig::new(
25451 workspace_id.clone(),
25452 std::env::current_dir().unwrap(),
25453 )
25454 .unwrap();
25455 let mut shared = NodeShared::new(
25456 handle,
25457 "fixture-token".to_owned(),
25458 NodeId::new("node-raw-semantic-gate").unwrap(),
25459 vec![workspace],
25460 vec![agent("claude")],
25461 );
25462 shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25463 crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25464 ])
25465 .unwrap();
25466 shared
25467 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25468 .unwrap();
25469 let address = SessionAddress {
25470 workspace_id,
25471 session: SessionKey {
25472 instance_id: AgentInstanceId(72),
25473 generation: SessionGeneration::default(),
25474 },
25475 };
25476 shared.bind_session_with_policy(
25477 &address,
25478 ProviderRuntimePolicy::raw_pty(),
25479 None,
25480 );
25481 shared
25482 .handle
25483 .dispatch(shared.prepare_command(ControlCommand::Register {
25484 instance_id: address.session.instance_id,
25485 agent_id: agent("claude"),
25486 transport: TransportKind::Pty,
25487 }))
25488 .unwrap();
25489 runtime.tick().await;
25490 let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25491 let revision_before = shared.handle.snapshot().revision;
25492 let history_sequence_before = shared.current_sequence();
25493 let terminal_size = gate4agent_types::TerminalSize {
25494 rows: 24,
25495 columns: 80,
25496 };
25497
25498 for request in [
25499 NodeRequest::Prompt {
25500 session: address.clone(),
25501 text: "prompt".to_owned(),
25502 },
25503 NodeRequest::Paste {
25504 session: address.clone(),
25505 text: "paste".to_owned(),
25506 },
25507 NodeRequest::Resume {
25513 session: address.clone(),
25514 terminal_size,
25515 initial_prompt: Some("resume".to_owned()),
25516 },
25517 ] {
25518 let error = process_request_inner(
25519 &shared,
25520 77,
25521 ClientRole::Operator,
25522 request,
25523 )
25524 .await
25525 .unwrap_err();
25526 assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25527 }
25528
25529 assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25530 assert_eq!(shared.handle.snapshot().revision, revision_before);
25531 assert_eq!(shared.current_sequence(), history_sequence_before);
25532 let bindings = shared
25533 .session_bindings
25534 .lock()
25535 .unwrap_or_else(|poisoned| poisoned.into_inner());
25536 let binding = bindings.get(&address.session.instance_id).unwrap();
25537 assert_eq!(binding.generation, address.session.generation);
25538 assert!(binding.pending_resume.is_none());
25539 assert!(binding.record_id.is_none());
25540 }
25541
25542 #[tokio::test]
25543 async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25544 let catalog = active_registry().unwrap();
25545 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25546 let primary = WorkspaceConfig::new(
25547 WorkspaceId::new("primary").unwrap(),
25548 std::env::current_dir().unwrap(),
25549 )
25550 .unwrap();
25551 let shared = NodeShared::new(
25552 handle,
25553 "fixture-token".to_owned(),
25554 NodeId::new("node-1").unwrap(),
25555 vec![primary],
25556 vec![agent("claude")],
25557 );
25558 shared
25559 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25560 .unwrap();
25561 let before = shared.snapshot();
25562 let error = process_request_inner(
25563 &shared,
25564 77,
25565 ClientRole::Operator,
25566 NodeRequest::RegisterWorkspace {
25567 workspace_id: WorkspaceId::new("foreign").unwrap(),
25568 root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25569 },
25570 )
25571 .await
25572 .unwrap_err();
25573 assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25574 assert_eq!(error.message, "invalid-request");
25575 assert_eq!(shared.snapshot().workspaces, before.workspaces);
25576 }
25577
25578 #[tokio::test]
25579 async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
25580 let catalog = active_registry().unwrap();
25581 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25582 let primary_id = WorkspaceId::new("primary").unwrap();
25583 let primary = WorkspaceConfig::new(
25584 primary_id.clone(),
25585 std::env::current_dir().unwrap(),
25586 )
25587 .unwrap();
25588 let shared = NodeShared::new(
25589 handle,
25590 "fixture-token".to_owned(),
25591 NodeId::new("node-1").unwrap(),
25592 vec![primary.clone()],
25593 vec![agent("claude")],
25594 );
25595 let secondary_id = WorkspaceId::new("secondary").unwrap();
25596 let secondary = shared
25597 .register_workspace(
25598 secondary_id.clone(),
25599 std::env::temp_dir().to_string_lossy().into_owned(),
25600 )
25601 .await
25602 .unwrap();
25603 assert_eq!(secondary.workspace_id, secondary_id);
25604 assert!(secondary.sessions.is_empty());
25605 assert_eq!(shared.snapshot().workspaces.len(), 2);
25606
25607 let duplicate_id = shared
25608 .register_workspace(
25609 secondary_id.clone(),
25610 primary.canonical_root().to_owned(),
25611 )
25612 .await
25613 .unwrap_err();
25614 assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
25615 let duplicate_root = shared
25616 .register_workspace(
25617 WorkspaceId::new("duplicate-root").unwrap(),
25618 windows_path_text(&secondary.canonical_root).to_owned(),
25619 )
25620 .await
25621 .unwrap_err();
25622 assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
25623
25624 let address = SessionAddress {
25625 workspace_id: secondary_id.clone(),
25626 session: SessionKey {
25627 instance_id: AgentInstanceId(41),
25628 generation: SessionGeneration(1),
25629 },
25630 };
25631 shared.bind_session(
25632 address.session.instance_id,
25633 secondary_id.clone(),
25634 address.session.generation,
25635 );
25636 let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
25637 assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
25638 shared.remove_binding(&address);
25639 shared.unregister_workspace(&secondary_id).unwrap();
25640 assert_eq!(shared.snapshot().workspaces.len(), 1);
25641
25642 let last = shared.unregister_workspace(&primary_id).unwrap_err();
25643 assert_eq!(last.code, NodeFailureCode::LastWorkspace);
25644 let history = shared
25645 .history
25646 .lock()
25647 .unwrap_or_else(|poisoned| poisoned.into_inner());
25648 assert!(matches!(
25649 &history.events[0],
25650 NodeEventEnvelope {
25651 sequence: 1,
25652 event: NodeEvent::WorkspaceAdded { workspace },
25653 } if workspace.workspace_id == secondary_id
25654 ));
25655 assert!(matches!(
25656 &history.events[1],
25657 NodeEventEnvelope {
25658 sequence: 2,
25659 event: NodeEvent::WorkspaceRemoved { workspace_id },
25660 } if workspace_id == &secondary_id
25661 ));
25662 }
25663
25664 #[tokio::test]
25665 async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
25666 let secondary_root = temporary_workspace_root("provider-session-reference-index");
25667 std::fs::create_dir_all(&secondary_root).unwrap();
25668 let catalog = active_registry().unwrap();
25669 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25670 let primary_id = WorkspaceId::new("primary").unwrap();
25671 let secondary_id = WorkspaceId::new("secondary").unwrap();
25672 let shared = NodeShared::new(
25673 handle,
25674 "fixture-token".to_owned(),
25675 NodeId::new("node-session-reference-index").unwrap(),
25676 vec![
25677 WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
25678 .unwrap(),
25679 WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
25680 ],
25681 vec![agent("claude"), agent("codex")],
25682 );
25683 let identity = ProviderSessionIdentity {
25684 key: gate4agent_types::ProviderSessionKey::SessionId,
25685 id: "provider-session-42".to_owned(),
25686 transcript_path: None,
25687 };
25688 let index_request = |workspace_id: WorkspaceId,
25689 provider: AgentId,
25690 identity: ProviderSessionIdentity,
25691 display_name: &str| {
25692 NodeRequest::IndexProviderSession {
25693 workspace_id,
25694 provider,
25695 identity,
25696 display_name: display_name.to_owned(),
25697 }
25698 };
25699 let request = index_request(
25700 primary_id.clone(),
25701 agent("claude"),
25702 identity.clone(),
25703 "release shepherd",
25704 );
25705 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25706 capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
25707 }));
25708 let observer = process_request_inner(
25709 &shared,
25710 77,
25711 ClientRole::Observer,
25712 request.clone(),
25713 )
25714 .await
25715 .unwrap_err();
25716 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25717 let uncontrolled = process_request_inner(
25718 &shared,
25719 77,
25720 ClientRole::Operator,
25721 request.clone(),
25722 )
25723 .await
25724 .unwrap_err();
25725 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25726 shared
25727 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25728 .unwrap();
25729
25730 let indexed = process_request_inner(
25731 &shared,
25732 77,
25733 ClientRole::Operator,
25734 request.clone(),
25735 )
25736 .await
25737 .unwrap();
25738 let NodeResponse::ProviderSessionIndexed { record } = indexed else {
25739 panic!("provider session reference index returned another response");
25740 };
25741 assert_eq!(record.state, ManagedSessionState::Dormant);
25742 assert_eq!(record.mode, SessionMode::Pty);
25743 assert_eq!(record.workspace_id, primary_id);
25744 assert_eq!(record.provider_session.as_ref(), Some(&identity));
25745 assert!(record.active_session.is_none());
25746 assert!(record.environment_profile.is_none());
25747 assert!(record.bundle.is_none());
25748 assert!(record.context.is_none());
25749
25750 let repeated = process_request_inner(
25751 &shared,
25752 77,
25753 ClientRole::Operator,
25754 index_request(
25755 primary_id.clone(),
25756 agent("claude"),
25757 identity.clone(),
25758 "ignored replacement",
25759 ),
25760 )
25761 .await
25762 .unwrap();
25763 assert!(matches!(
25764 repeated,
25765 NodeResponse::ProviderSessionIndexed { record: ref existing }
25766 if existing.record_id == record.record_id
25767 && existing.display_name == "release shepherd"
25768 ));
25769 let conflicting_workspace = process_request_inner(
25770 &shared,
25771 77,
25772 ClientRole::Operator,
25773 index_request(
25774 secondary_id,
25775 agent("claude"),
25776 identity.clone(),
25777 "release shepherd",
25778 ),
25779 )
25780 .await
25781 .unwrap_err();
25782 assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
25783 let conflicting_provider = process_request_inner(
25784 &shared,
25785 77,
25786 ClientRole::Operator,
25787 index_request(
25788 primary_id.clone(),
25789 agent("codex"),
25790 identity.clone(),
25791 "release shepherd",
25792 ),
25793 )
25794 .await
25795 .unwrap_err();
25796 assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
25797 let transcript_rejected = process_request_inner(
25798 &shared,
25799 77,
25800 ClientRole::Operator,
25801 index_request(
25802 primary_id,
25803 agent("claude"),
25804 ProviderSessionIdentity {
25805 transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
25806 ..identity
25807 },
25808 "release shepherd",
25809 ),
25810 )
25811 .await
25812 .unwrap_err();
25813 assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
25814 let upserts = shared
25815 .history
25816 .lock()
25817 .unwrap_or_else(|poisoned| poisoned.into_inner())
25818 .events
25819 .iter()
25820 .filter(|event| matches!(
25821 &event.event,
25822 NodeEvent::SessionRecordUpserted { record: published }
25823 if published.record_id == record.record_id
25824 ))
25825 .count();
25826 assert_eq!(upserts, 1);
25827 std::fs::remove_dir_all(secondary_root).unwrap();
25828 }
25829
25830 #[tokio::test]
25831 async fn standalone_workspace_is_independent_registered_and_controller_gated() {
25832 let fixture_root = temporary_workspace_root("standalone-independent");
25833 let target = fixture_root.join("independent");
25834 std::fs::create_dir_all(&fixture_root).unwrap();
25835 let catalog = active_registry().unwrap();
25836 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25837 let selected = WorkspaceConfig::new(
25838 WorkspaceId::new("primary").unwrap(),
25839 std::env::current_dir().unwrap(),
25840 )
25841 .unwrap();
25842 let selected_root = selected.canonical_root().to_owned();
25843 let shared = NodeShared::new(
25844 handle,
25845 "fixture-token".to_owned(),
25846 NodeId::new("node-standalone").unwrap(),
25847 vec![selected],
25848 vec![agent("claude")],
25849 );
25850 let workspace_id = WorkspaceId::new("independent").unwrap();
25851 let request = NodeRequest::CreateStandaloneWorkspace {
25852 workspace_id: workspace_id.clone(),
25853 root: opaque_windows_path(target.to_string_lossy().into_owned()),
25854 initial_branch: Some("main".to_owned()),
25855 };
25856 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
25857 capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
25858 }));
25859 assert!(!request_uses_unnegotiated_capability(
25860 &request,
25861 &baseline_capabilities().unwrap(),
25862 ));
25863 assert!(request_uses_unnegotiated_capability(&request, &[]));
25864
25865 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
25866 .await
25867 .unwrap_err();
25868 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
25869 assert!(!target.exists());
25870 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
25871 .await
25872 .unwrap_err();
25873 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
25874 assert!(!target.exists());
25875 shared
25876 .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25877 .unwrap();
25878
25879 let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
25880 .await
25881 .unwrap();
25882 let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
25883 panic!("standalone workspace creation returned another response");
25884 };
25885 assert_eq!(workspace.workspace_id, workspace_id);
25886 assert!(target.join(".git").is_dir());
25887 let branch = run_git_bounded(
25888 target.to_str().unwrap(),
25889 &["branch", "--show-current"],
25890 GIT_OUTPUT_MAX_BYTES,
25891 )
25892 .await
25893 .unwrap();
25894 assert!(branch.success);
25895 assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
25896 let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
25897 assert_eq!(worktrees.len(), 1);
25898 let listed_root = WorkspaceConfig::new(
25899 WorkspaceId::new("listed-independent").unwrap(),
25900 &worktrees[0].path,
25901 )
25902 .unwrap();
25903 assert!(platform::roots_equal(
25904 listed_root.canonical_root(),
25905 windows_path_text(&workspace.canonical_root),
25906 ));
25907 assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
25908 assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
25909 snapshot.workspace_id == workspace_id
25910 && snapshot.canonical_root == workspace.canonical_root
25911 }));
25912 assert!(shared
25913 .history
25914 .lock()
25915 .unwrap_or_else(|poisoned| poisoned.into_inner())
25916 .events
25917 .iter()
25918 .any(|event| matches!(
25919 &event.event,
25920 NodeEvent::WorkspaceAdded { workspace }
25921 if workspace.workspace_id == workspace_id
25922 )));
25923 std::fs::remove_dir_all(fixture_root).unwrap();
25924 }
25925
25926 #[tokio::test]
25927 async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
25928 let fixture_root = temporary_workspace_root("standalone-compensation");
25929 let created_target = fixture_root.join("created-target");
25930 let existing_target = fixture_root.join("existing-target");
25931 let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
25932 let invalid_state_path = invalid_state_parent.join("state.json");
25933 std::fs::create_dir_all(&existing_target).unwrap();
25934 std::fs::write(&invalid_state_parent, "not a directory").unwrap();
25935 let catalog = active_registry().unwrap();
25936 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25937 let primary = WorkspaceConfig::new(
25938 WorkspaceId::new("primary").unwrap(),
25939 std::env::current_dir().unwrap(),
25940 )
25941 .unwrap();
25942 let mut shared = NodeShared::new(
25943 handle,
25944 "fixture-token".to_owned(),
25945 NodeId::new("node-standalone-compensation").unwrap(),
25946 vec![primary],
25947 vec![agent("claude")],
25948 );
25949 shared.state_path = Some(invalid_state_path);
25950
25951 let created_recovery = shared
25952 .create_standalone_workspace(
25953 WorkspaceId::new("compensated").unwrap(),
25954 created_target.to_string_lossy().into_owned(),
25955 Some("main".to_owned()),
25956 )
25957 .await
25958 .unwrap_err();
25959 assert_eq!(
25960 created_recovery.code,
25961 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25962 );
25963 assert!(created_target.join(".git").is_dir());
25964 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25965 workspace.workspace_id.as_str() != "compensated"
25966 }));
25967
25968 let existing_recovery = shared
25969 .create_standalone_workspace(
25970 WorkspaceId::new("restored").unwrap(),
25971 existing_target.to_string_lossy().into_owned(),
25972 None,
25973 )
25974 .await
25975 .unwrap_err();
25976 assert_eq!(
25977 existing_recovery.code,
25978 NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
25979 );
25980 assert!(existing_target.is_dir());
25981 assert!(existing_target.join(".git").is_dir());
25982 assert!(shared.snapshot().workspaces.iter().all(|workspace| {
25983 workspace.workspace_id.as_str() != "restored"
25984 }));
25985
25986 let registered_empty = fixture_root.join("registered-empty");
25987 std::fs::create_dir(®istered_empty).unwrap();
25988 let catalog = active_registry().unwrap();
25989 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25990 let registered = WorkspaceConfig::new(
25991 WorkspaceId::new("registered-empty").unwrap(),
25992 ®istered_empty,
25993 )
25994 .unwrap();
25995 let duplicate_shared = NodeShared::new(
25996 handle,
25997 "fixture-token".to_owned(),
25998 NodeId::new("node-standalone-duplicate-root").unwrap(),
25999 vec![registered],
26000 vec![agent("claude")],
26001 );
26002 let duplicate = duplicate_shared
26003 .create_standalone_workspace(
26004 WorkspaceId::new("duplicate-root").unwrap(),
26005 registered_empty.to_string_lossy().into_owned(),
26006 None,
26007 )
26008 .await
26009 .unwrap_err();
26010 assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26011 assert!(std::fs::read_dir(®istered_empty).unwrap().next().is_none());
26012 std::fs::remove_dir_all(fixture_root).unwrap();
26013 }
26014
26015 #[test]
26016 fn managed_record_id_collision_preserves_the_existing_record() {
26017 let catalog = active_registry().unwrap();
26018 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26019 let workspace = WorkspaceConfig::new(
26020 WorkspaceId::new("primary").unwrap(),
26021 std::env::current_dir().unwrap(),
26022 )
26023 .unwrap();
26024 let shared = NodeShared::new(
26025 handle,
26026 "fixture-token".to_owned(),
26027 NodeId::new("node-1").unwrap(),
26028 vec![workspace.clone()],
26029 vec![agent("claude")],
26030 );
26031 let record_id = SessionRecordId::new("sr-collision").unwrap();
26032 let original = ManagedSessionRecord {
26033 record_id: record_id.clone(),
26034 display_name: "original".to_owned(),
26035 provider: agent("claude"),
26036 mode: SessionMode::Pty,
26037 state: ManagedSessionState::Dormant,
26038 workspace_id: workspace.workspace_id,
26039 canonical_root: opaque_windows_path(workspace.canonical_root),
26040 provider_session: None,
26041 active_session: None,
26042 environment_profile: None,
26043 bundle: None,
26044 context_id: None,
26045 context: None,
26046 exported_context: None,
26047 task_binding: None,
26048 created_at_unix_ms: 1,
26049 updated_at_unix_ms: 1,
26050 last_error: None,
26051 };
26052 shared.insert_record(original.clone()).unwrap();
26053 let mut collision = original.clone();
26054 collision.display_name = "replacement".to_owned();
26055 let error = shared.insert_record(collision).unwrap_err();
26056 assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26057 assert_eq!(shared.record(&record_id).unwrap(), original);
26058 }
26059
26060 #[test]
26061 fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26062 let catalog = active_registry().unwrap();
26063 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26064 let workspace = WorkspaceConfig::new(
26065 WorkspaceId::new("primary").unwrap(),
26066 std::env::current_dir().unwrap(),
26067 )
26068 .unwrap();
26069 let shared = NodeShared::new(
26070 handle,
26071 "fixture-token".to_owned(),
26072 NodeId::new("node-1").unwrap(),
26073 vec![workspace.clone()],
26074 vec![agent("claude")],
26075 );
26076 let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26077 shared
26078 .insert_record(ManagedSessionRecord {
26079 record_id: record_id.clone(),
26080 display_name: "runtime error".to_owned(),
26081 provider: agent("claude"),
26082 mode: SessionMode::Pty,
26083 state: ManagedSessionState::IdentityPending,
26084 workspace_id: workspace.workspace_id,
26085 canonical_root: opaque_windows_path(workspace.canonical_root),
26086 provider_session: None,
26087 active_session: None,
26088 environment_profile: None,
26089 bundle: None,
26090 context_id: None,
26091 context: None,
26092 exported_context: None,
26093 task_binding: None,
26094 created_at_unix_ms: 1,
26095 updated_at_unix_ms: 1,
26096 last_error: None,
26097 })
26098 .unwrap();
26099
26100 shared
26101 .mark_record_error(
26102 &record_id,
26103 r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26104 )
26105 .unwrap();
26106 let record = shared.record(&record_id).unwrap();
26107 assert_eq!(record.state, ManagedSessionState::Unavailable);
26108 assert_eq!(
26109 record.last_error.as_deref(),
26110 Some("provider-runtime-failed"),
26111 );
26112 let snapshot = shared.snapshot();
26113 assert_eq!(snapshot.session_records, vec![record]);
26114 }
26115
26116 #[test]
26125 fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26126 let catalog = active_registry().unwrap();
26127 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26128 let workspace = WorkspaceConfig::new(
26129 WorkspaceId::new("primary").unwrap(),
26130 std::env::current_dir().unwrap(),
26131 )
26132 .unwrap();
26133 let shared = NodeShared::new(
26134 handle,
26135 "fixture-token".to_owned(),
26136 NodeId::new("node-1").unwrap(),
26137 vec![workspace],
26138 vec![agent("claude")],
26139 );
26140
26141 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26142
26143 shared.record_rejected_command(
26144 CommandId(1),
26145 "agent 'claude' does not support transport Acp".to_owned(),
26146 true,
26147 );
26148 assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26150
26151 let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26152 assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26153 assert!(reason.unsupported_transport);
26154
26155 assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26157
26158 shared.record_rejected_command(
26163 CommandId(3),
26164 "backend busy".to_owned(),
26165 false,
26166 );
26167 let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26168 assert!(!unclassified.unsupported_transport);
26169 }
26170
26171 #[test]
26172 fn provider_identity_update_is_ignored_without_exact_session_policy() {
26173 let catalog = active_registry().unwrap();
26174 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26175 let workspace = WorkspaceConfig::new(
26176 WorkspaceId::new("primary").unwrap(),
26177 std::env::current_dir().unwrap(),
26178 )
26179 .unwrap();
26180 let shared = NodeShared::new(
26181 handle,
26182 "fixture-token".to_owned(),
26183 NodeId::new("node-identity-policy").unwrap(),
26184 vec![workspace.clone()],
26185 vec![agent("claude")],
26186 );
26187 let address = SessionAddress {
26188 workspace_id: workspace.workspace_id.clone(),
26189 session: SessionKey {
26190 instance_id: AgentInstanceId(90),
26191 generation: SessionGeneration(1),
26192 },
26193 };
26194 let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26195 shared
26196 .insert_record(ManagedSessionRecord {
26197 record_id: record_id.clone(),
26198 display_name: "policy denied".to_owned(),
26199 provider: agent("claude"),
26200 mode: SessionMode::Pty,
26201 state: ManagedSessionState::IdentityPending,
26202 workspace_id: workspace.workspace_id,
26203 canonical_root: opaque_windows_path(workspace.canonical_root),
26204 provider_session: None,
26205 active_session: Some(address.clone()),
26206 environment_profile: None,
26207 bundle: None,
26208 context_id: None,
26209 context: None,
26210 exported_context: None,
26211 task_binding: None,
26212 created_at_unix_ms: 1,
26213 updated_at_unix_ms: 1,
26214 last_error: None,
26215 })
26216 .unwrap();
26217 let event = ControlEvent {
26218 sequence: 1,
26219 command_id: None,
26220 instance_id: address.session.instance_id,
26221 generation: address.session.generation,
26222 event: ControlEventKind::ProviderEvent {
26223 sequence: 1,
26224 source: gate4agent_types::ProviderSource {
26225 family: AdapterFamily::PtySemantic,
26226 binding: AdapterBinding::new(
26227 gate4agent_types::AdapterId::new("claude").unwrap(),
26228 "fixture/v1",
26229 gate4agent_types::AdapterVerification::SyntheticFixture,
26230 )
26231 .unwrap(),
26232 },
26233 source_sequence: 1,
26234 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26235 identity: gate4agent_types::ProviderSessionIdentity {
26236 key: gate4agent_types::ProviderSessionKey::SessionId,
26237 id: "provider-session".to_owned(),
26238 transcript_path: None,
26239 },
26240 },
26241 },
26242 };
26243
26244 shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26245
26246 let record = shared.record(&record_id).unwrap();
26247 assert!(record.provider_session.is_none());
26248 assert_eq!(record.state, ManagedSessionState::IdentityPending);
26249 assert_eq!(record.active_session.as_ref(), Some(&address));
26250 }
26251
26252 #[test]
26253 fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26254 let secondary_root = temporary_workspace_root("identity-scope");
26255 std::fs::create_dir_all(&secondary_root).unwrap();
26256 let catalog = active_registry().unwrap();
26257 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26258 let primary = WorkspaceConfig::new(
26259 WorkspaceId::new("primary").unwrap(),
26260 std::env::current_dir().unwrap(),
26261 )
26262 .unwrap();
26263 let secondary = WorkspaceConfig::new(
26264 WorkspaceId::new("secondary").unwrap(),
26265 &secondary_root,
26266 )
26267 .unwrap();
26268 let shared = NodeShared::new(
26269 handle,
26270 "fixture-token".to_owned(),
26271 NodeId::new("node-1").unwrap(),
26272 vec![primary.clone(), secondary.clone()],
26273 vec![agent("claude")],
26274 );
26275 let identity = gate4agent_types::ProviderSessionIdentity {
26276 key: gate4agent_types::ProviderSessionKey::SessionId,
26277 id: "provider-session".to_owned(),
26278 transcript_path: None,
26279 };
26280 let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26281 shared
26282 .insert_record(ManagedSessionRecord {
26283 record_id: dormant_id.clone(),
26284 display_name: "dormant".to_owned(),
26285 provider: agent("claude"),
26286 mode: SessionMode::Pty,
26287 state: ManagedSessionState::Dormant,
26288 workspace_id: primary.workspace_id,
26289 canonical_root: opaque_windows_path(primary.canonical_root),
26290 provider_session: Some(identity.clone()),
26291 active_session: None,
26292 environment_profile: None,
26293 bundle: None,
26294 context_id: None,
26295 context: None,
26296 exported_context: None,
26297 task_binding: None,
26298 created_at_unix_ms: 1,
26299 updated_at_unix_ms: 1,
26300 last_error: None,
26301 })
26302 .unwrap();
26303 let address = SessionAddress {
26304 workspace_id: secondary.workspace_id.clone(),
26305 session: SessionKey {
26306 instance_id: AgentInstanceId(91),
26307 generation: SessionGeneration(1),
26308 },
26309 };
26310 let current_id = SessionRecordId::new("sr-current").unwrap();
26311 shared
26312 .insert_record(ManagedSessionRecord {
26313 record_id: current_id.clone(),
26314 display_name: "current".to_owned(),
26315 provider: agent("claude"),
26316 mode: SessionMode::Pty,
26317 state: ManagedSessionState::IdentityPending,
26318 workspace_id: secondary.workspace_id,
26319 canonical_root: opaque_windows_path(secondary.canonical_root),
26320 provider_session: None,
26321 active_session: Some(address.clone()),
26322 environment_profile: None,
26323 bundle: None,
26324 context_id: None,
26325 context: None,
26326 exported_context: None,
26327 task_binding: None,
26328 created_at_unix_ms: 2,
26329 updated_at_unix_ms: 2,
26330 last_error: None,
26331 })
26332 .unwrap();
26333 shared.bind_managed_session(
26334 &address,
26335 current_id.clone(),
26336 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26337 None,
26338 );
26339 let event = ControlEvent {
26340 sequence: 1,
26341 command_id: None,
26342 instance_id: address.session.instance_id,
26343 generation: address.session.generation,
26344 event: ControlEventKind::ProviderEvent {
26345 sequence: 1,
26346 source: gate4agent_types::ProviderSource {
26347 family: gate4agent_types::AdapterFamily::PtySemantic,
26348 binding: gate4agent_types::AdapterBinding::new(
26349 gate4agent_types::AdapterId::new("claude").unwrap(),
26350 "fixture/v1",
26351 gate4agent_types::AdapterVerification::SyntheticFixture,
26352 )
26353 .unwrap(),
26354 },
26355 source_sequence: 1,
26356 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26357 identity,
26358 },
26359 },
26360 };
26361 shared.reconcile_managed_record(¤t_id, &address, &event, true, false);
26362
26363 let binding = shared
26364 .session_bindings
26365 .lock()
26366 .unwrap_or_else(|poisoned| poisoned.into_inner())
26367 .get(&address.session.instance_id)
26368 .cloned()
26369 .unwrap();
26370 assert_eq!(binding.record_id.as_ref(), Some(¤t_id));
26371 assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26372 let current = shared.record(¤t_id).unwrap();
26373 assert_eq!(current.state, ManagedSessionState::Unavailable);
26374 assert_eq!(current.active_session.as_ref(), Some(&address));
26375 assert_eq!(
26376 current.last_error.as_deref(),
26377 Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26378 );
26379 std::fs::remove_dir_all(secondary_root).unwrap();
26380 }
26381
26382 #[tokio::test]
26383 async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26384 let root = temporary_workspace_root("semantic-provider-session");
26385 std::fs::create_dir_all(&root).unwrap();
26386 let state_path = root.join("state-v1.json");
26387 let catalog = active_registry().unwrap();
26388 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26389 let workspace = WorkspaceConfig::new(
26390 WorkspaceId::new("primary").unwrap(),
26391 &root,
26392 )
26393 .unwrap();
26394 let node_id = NodeId::new("node-semantic-identity").unwrap();
26395 let shared = NodeShared::new_with_incarnation(
26396 handle,
26397 "fixture-token".to_owned(),
26398 node_id.clone(),
26399 NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26400 vec![workspace.clone()],
26401 vec![agent("claude")],
26402 ProviderRuntimeStatuses::default(),
26403 None,
26404 Vec::new(),
26405 Vec::new(),
26406 SpawnProfileRegistry::default(),
26407 None,
26408 None,
26409 None,
26410 Some(state_path.clone()),
26411 Vec::new(),
26412 Vec::new(),
26413 Vec::new(),
26414 Vec::new(),
26415 None,
26416 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26417 );
26418 let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26419 key: gate4agent_types::ProviderSessionKey::SessionId,
26420 id: "provider-session".to_owned(),
26421 transcript_path: None,
26422 };
26423 let durable_id = SessionRecordId::new("sr-durable").unwrap();
26424 shared
26425 .insert_record(ManagedSessionRecord {
26426 record_id: durable_id.clone(),
26427 display_name: "durable".to_owned(),
26428 provider: agent("claude"),
26429 mode: SessionMode::Pty,
26430 state: ManagedSessionState::Dormant,
26431 workspace_id: workspace.workspace_id.clone(),
26432 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26433 provider_session: Some(persisted_identity.clone()),
26434 active_session: None,
26435 environment_profile: None,
26436 bundle: None,
26437 context_id: None,
26438 context: None,
26439 exported_context: None,
26440 task_binding: None,
26441 created_at_unix_ms: 1,
26442 updated_at_unix_ms: 1,
26443 last_error: None,
26444 })
26445 .unwrap();
26446 let address = SessionAddress {
26447 workspace_id: workspace.workspace_id.clone(),
26448 session: SessionKey {
26449 instance_id: AgentInstanceId(92),
26450 generation: SessionGeneration(2),
26451 },
26452 };
26453 let pending_id = SessionRecordId::new("sr-pending").unwrap();
26454 shared
26455 .insert_record(ManagedSessionRecord {
26456 record_id: pending_id.clone(),
26457 display_name: "pending".to_owned(),
26458 provider: agent("claude"),
26459 mode: SessionMode::Pty,
26460 state: ManagedSessionState::IdentityPending,
26461 workspace_id: workspace.workspace_id.clone(),
26462 canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26463 provider_session: None,
26464 active_session: Some(address.clone()),
26465 environment_profile: None,
26466 bundle: None,
26467 context_id: None,
26468 context: None,
26469 exported_context: None,
26470 task_binding: None,
26471 created_at_unix_ms: 2,
26472 updated_at_unix_ms: 2,
26473 last_error: None,
26474 })
26475 .unwrap();
26476 shared.bind_managed_session(
26477 &address,
26478 pending_id.clone(),
26479 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26480 None,
26481 );
26482 let observed_identity = gate4agent_types::ProviderSessionIdentity {
26483 transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26484 ..persisted_identity
26485 };
26486 let event = ControlEvent {
26487 sequence: 1,
26488 command_id: None,
26489 instance_id: address.session.instance_id,
26490 generation: address.session.generation,
26491 event: ControlEventKind::ProviderEvent {
26492 sequence: 1,
26493 source: gate4agent_types::ProviderSource {
26494 family: gate4agent_types::AdapterFamily::PtySemantic,
26495 binding: gate4agent_types::AdapterBinding::new(
26496 gate4agent_types::AdapterId::new("claude").unwrap(),
26497 "fixture/v1",
26498 gate4agent_types::AdapterVerification::SyntheticFixture,
26499 )
26500 .unwrap(),
26501 },
26502 source_sequence: 1,
26503 event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26504 identity: observed_identity.clone(),
26505 },
26506 },
26507 };
26508 shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26509
26510 let snapshot = shared.snapshot();
26511 assert_eq!(snapshot.session_records.len(), 1);
26512 let live = &snapshot.session_records[0];
26513 assert_eq!(live.record_id, durable_id);
26514 assert_eq!(live.state, ManagedSessionState::Live);
26515 assert_eq!(live.active_session.as_ref(), Some(&address));
26516 assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26517 let binding = shared
26518 .session_bindings
26519 .lock()
26520 .unwrap_or_else(|poisoned| poisoned.into_inner())
26521 .get(&address.session.instance_id)
26522 .cloned()
26523 .unwrap();
26524 assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26525
26526 let mut duplicate = live.clone();
26527 duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26528 duplicate.display_name = "duplicate".to_owned();
26529 duplicate.state = ManagedSessionState::Dormant;
26530 duplicate.active_session = None;
26531 duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26532 let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26533 assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26534 assert_eq!(shared.snapshot().session_records.len(), 1);
26535
26536 let terminal_size = gate4agent_types::TerminalSize {
26537 rows: 24,
26538 columns: 80,
26539 };
26540 let (first_busy, second_busy) = tokio::join!(
26541 shared.resume_session_record(&durable_id, terminal_size, None),
26542 shared.resume_session_record(&durable_id, terminal_size, None),
26543 );
26544 for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26545 assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26546 assert_eq!(busy.message, "session-record-busy");
26547 }
26548
26549 let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26550 assert_eq!(loaded.records.len(), 1);
26551 assert_eq!(loaded.records[0].record_id, durable_id);
26552 assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26553 assert_eq!(
26554 loaded.records[0]
26555 .provider_session
26556 .as_ref()
26557 .unwrap()
26558 .transcript_path,
26559 None,
26560 );
26561 let catalog = active_registry().unwrap();
26562 let (reloaded_handle, _reloaded_runtime) =
26563 NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26564 let reloaded = NodeShared::new_with_incarnation(
26565 reloaded_handle,
26566 "fixture-token".to_owned(),
26567 node_id,
26568 NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26569 vec![workspace],
26570 vec![agent("claude")],
26571 ProviderRuntimeStatuses::default(),
26572 None,
26573 Vec::new(),
26574 Vec::new(),
26575 SpawnProfileRegistry::default(),
26576 None,
26577 None,
26578 None,
26579 None,
26580 loaded.records,
26581 loaded.managed_worktrees,
26582 loaded.managed_worktree_tombstones,
26583 loaded.materializations,
26584 loaded.warning,
26585 MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26586 );
26587 assert_eq!(reloaded.snapshot().session_records.len(), 1);
26588 assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
26589 std::fs::remove_dir_all(root).unwrap();
26590 }
26591
26592 #[tokio::test]
26593 async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
26594 let secondary_root = temporary_workspace_root("workspace-restore");
26595 let other_root = temporary_workspace_root("workspace-conflict");
26596 std::fs::create_dir_all(&secondary_root).unwrap();
26597 std::fs::create_dir_all(&other_root).unwrap();
26598 let catalog = active_registry().unwrap();
26599 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26600 let primary = WorkspaceConfig::new(
26601 WorkspaceId::new("primary").unwrap(),
26602 std::env::current_dir().unwrap(),
26603 )
26604 .unwrap();
26605 let secondary = WorkspaceConfig::new(
26606 WorkspaceId::new("secondary").unwrap(),
26607 &secondary_root,
26608 )
26609 .unwrap();
26610 let shared = NodeShared::new(
26611 handle,
26612 "fixture-token".to_owned(),
26613 NodeId::new("node-1").unwrap(),
26614 vec![primary, secondary.clone()],
26615 vec![agent("claude")],
26616 );
26617 let record_id = SessionRecordId::new("sr-workspace").unwrap();
26618 shared
26619 .insert_record(ManagedSessionRecord {
26620 record_id: record_id.clone(),
26621 display_name: "workspace record".to_owned(),
26622 provider: agent("claude"),
26623 mode: SessionMode::Pty,
26624 state: ManagedSessionState::Dormant,
26625 workspace_id: secondary.workspace_id.clone(),
26626 canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
26627 provider_session: Some(gate4agent_types::ProviderSessionIdentity {
26628 key: gate4agent_types::ProviderSessionKey::SessionId,
26629 id: "provider-session".to_owned(),
26630 transcript_path: None,
26631 }),
26632 active_session: None,
26633 environment_profile: None,
26634 bundle: None,
26635 context_id: None,
26636 context: None,
26637 exported_context: None,
26638 task_binding: None,
26639 created_at_unix_ms: 1,
26640 updated_at_unix_ms: 1,
26641 last_error: None,
26642 })
26643 .unwrap();
26644 shared.unregister_workspace(&secondary.workspace_id).unwrap();
26645 let unavailable = shared.record(&record_id).unwrap();
26646 assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
26647 assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
26648
26649 let conflict = shared
26650 .register_workspace(
26651 secondary.workspace_id.clone(),
26652 other_root.to_string_lossy().into_owned(),
26653 )
26654 .await
26655 .unwrap_err();
26656 assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
26657 shared
26658 .register_workspace(
26659 secondary.workspace_id,
26660 secondary_root.to_string_lossy().into_owned(),
26661 )
26662 .await
26663 .unwrap();
26664 let restored = shared.record(&record_id).unwrap();
26665 assert_eq!(restored.state, ManagedSessionState::Dormant);
26666 assert!(restored.last_error.is_none());
26667 std::fs::remove_dir_all(secondary_root).unwrap();
26668 std::fs::remove_dir_all(other_root).unwrap();
26669 }
26670
26671 #[test]
26672 fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
26673 let error = validate_workspace_request_root(
26674 &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
26675 )
26676 .unwrap_err();
26677 assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
26678 let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
26679 assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
26680 }
26681
26682 #[test]
26683 fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
26684 let catalog = active_registry().unwrap();
26685 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26686 let workspace = WorkspaceConfig::new(
26687 WorkspaceId::new("primary").unwrap(),
26688 std::env::current_dir().unwrap(),
26689 )
26690 .unwrap();
26691 let shared = NodeShared::new(
26692 handle,
26693 "fixture-token".to_owned(),
26694 NodeId::new("node-1").unwrap(),
26695 vec![workspace],
26696 vec![agent("claude")],
26697 );
26698 let publishers = 4_u64;
26699 let events_per_publisher = 256_u64;
26700 let start = std::sync::Barrier::new(publishers as usize + 1);
26701 let finished = AtomicU64::new(0);
26702
26703 std::thread::scope(|scope| {
26704 for _ in 0..publishers {
26705 scope.spawn(|| {
26706 start.wait();
26707 for _ in 0..events_per_publisher {
26708 shared.publish(NodeEvent::ControllerChanged { controller: None });
26709 }
26710 finished.fetch_add(1, Ordering::Release);
26711 });
26712 }
26713 start.wait();
26714 while finished.load(Ordering::Acquire) != publishers {
26715 assert_resync_history_is_linearized(shared.resync(0));
26716 std::thread::yield_now();
26717 }
26718 });
26719 let response = shared.resync(0);
26720 assert_resync_history_is_linearized(response.clone());
26721 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26722 unreachable!("resync helper returned another response");
26723 };
26724 assert_eq!(event_sequence, publishers * events_per_publisher);
26725 assert_eq!(events.len() as u64, event_sequence);
26726 }
26727
26728 fn assert_resync_history_is_linearized(response: NodeResponse) {
26729 let NodeResponse::Resync { event_sequence, events, .. } = response else {
26730 unreachable!("resync helper returned another response");
26731 };
26732 assert!(events.windows(2).all(|pair| {
26733 pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
26734 }));
26735 assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
26736 }
26737
26738 #[test]
26739 fn canonical_workspace_roots_are_safe_for_cmd_exe() {
26740 assert_eq!(
26741 platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
26742 r"C:\repo\worktree",
26743 );
26744 assert_eq!(
26745 platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
26746 r"\\server\share\repo",
26747 );
26748 assert!(validate_workspace_root(
26749 &WorkspaceId::new("network").unwrap(),
26750 r"\\server\share\repo",
26751 )
26752 .is_err());
26753 let workspace = WorkspaceConfig::new(
26754 WorkspaceId::new("primary").unwrap(),
26755 std::env::current_dir().unwrap(),
26756 )
26757 .unwrap();
26758 assert!(!workspace.canonical_root.starts_with(r"\\?\"));
26759 }
26760
26761 #[test]
26762 fn resume_rebinds_only_on_the_expected_authorized_generation() {
26763 let catalog = active_registry().unwrap();
26764 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26765 let workspace_id = WorkspaceId::new("primary").unwrap();
26766 let workspace = WorkspaceConfig::new(
26767 workspace_id.clone(),
26768 std::env::current_dir().unwrap(),
26769 )
26770 .unwrap();
26771 let shared = NodeShared::new(
26772 handle,
26773 "fixture-token".to_owned(),
26774 NodeId::new("node-1").unwrap(),
26775 vec![workspace],
26776 vec![agent("claude")],
26777 );
26778 let instance_id = AgentInstanceId(17);
26779 let original = SessionAddress {
26780 workspace_id,
26781 session: SessionKey {
26782 instance_id,
26783 generation: SessionGeneration(3),
26784 },
26785 };
26786 shared.bind_session(
26787 instance_id,
26788 original.workspace_id.clone(),
26789 original.session.generation,
26790 );
26791 shared
26792 .arm_resume(
26793 &original,
26794 CommandId(41),
26795 ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26796 )
26797 .unwrap();
26798
26799 shared.publish_control(ControlEvent {
26800 sequence: 1,
26801 command_id: None,
26802 instance_id,
26803 generation: SessionGeneration(4),
26804 event: ControlEventKind::Running { process_id: None },
26805 });
26806 assert_eq!(
26807 shared.address_for(instance_id, SessionGeneration(3)),
26808 Some(original.clone()),
26809 );
26810 assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
26811
26812 shared.publish_control(ControlEvent {
26813 sequence: 2,
26814 command_id: Some(CommandId(42)),
26815 instance_id,
26816 generation: SessionGeneration(3),
26817 event: ControlEventKind::CommandRejected {
26818 message: "unrelated command".to_owned(),
26819 },
26820 });
26821
26822 shared.publish_control(ControlEvent {
26823 sequence: 3,
26824 command_id: None,
26825 instance_id,
26826 generation: SessionGeneration(4),
26827 event: ControlEventKind::ResumeAuthorized {
26828 session: gate4agent_types::ResumeSessionSummary {
26829 key: gate4agent_types::ProviderSessionKey::SessionId,
26830 id: "fixture-session".to_owned(),
26831 },
26832 },
26833 });
26834 assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
26835 let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
26836 assert_eq!(rebound.workspace_id, original.workspace_id);
26837 let history = shared
26838 .history
26839 .lock()
26840 .unwrap_or_else(|poisoned| poisoned.into_inner());
26841 assert!(history.events.iter().any(|envelope| matches!(
26842 &envelope.event,
26843 NodeEvent::Control { address, event }
26844 if address == &rebound
26845 && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
26846 )));
26847 }
26848
26849 #[test]
26850 fn all_node_pty_prompts_use_bracketed_paste_framing() {
26851 for agent in ["claude", "codex", "kimi"] {
26852 assert_eq!(
26853 prompt_framing(&AgentId::new(agent).unwrap()),
26854 PromptFraming::BracketedPaste
26855 );
26856 }
26857 }
26858
26859 fn generous_walk_deadline() -> Instant {
26860 Instant::now() + Duration::from_secs(30)
26861 }
26862
26863 #[test]
26864 fn workspace_tree_is_relative_and_skips_heavy_directories() {
26865 let root = temporary_workspace_root("tree");
26866 std::fs::create_dir_all(root.join("src")).unwrap();
26867 std::fs::create_dir_all(root.join(".git/objects")).unwrap();
26868 std::fs::create_dir_all(root.join("target/debug")).unwrap();
26869 std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
26870 std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
26871 std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
26872 std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
26878 std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
26879 std::fs::create_dir_all(root.join("target-notes")).unwrap();
26883 std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
26884
26885 let (entries, truncated, _budget) =
26886 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26887 assert!(!truncated);
26888 assert!(entries.iter().any(|entry| {
26889 entry.relative_path.as_utf8() == Some("src")
26890 && entry.kind == WorkspaceEntryKind::Directory
26891 }));
26892 assert!(entries.iter().any(|entry| {
26893 entry.relative_path.as_utf8() == Some("src/lib.rs")
26894 && entry.kind == WorkspaceEntryKind::File
26895 }));
26896 assert!(entries.iter().any(|entry| {
26897 entry.relative_path.as_utf8() == Some("target-notes")
26898 && entry.kind == WorkspaceEntryKind::Directory
26899 }));
26900 assert!(entries.iter().any(|entry| {
26901 entry.relative_path.as_utf8() == Some("target-notes/plan.md")
26902 && entry.kind == WorkspaceEntryKind::File
26903 }));
26904 assert!(entries.iter().all(|entry| {
26905 let path = entry.relative_path.as_utf8().unwrap();
26906 !path.starts_with(".git")
26907 && !(path == "target" || path.starts_with("target/"))
26908 && !(path == "target-a2" || path.starts_with("target-a2/"))
26909 && !path.starts_with("node_modules")
26910 }));
26911
26912 std::fs::remove_dir_all(&root).unwrap();
26913 }
26914
26915 #[test]
26916 fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
26917 let root = temporary_workspace_root("tree-entry-cap");
26918 std::fs::create_dir_all(&root).unwrap();
26919 for index in 0..20 {
26920 std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
26921 }
26922
26923 let (entries, truncated, budget) =
26924 collect_workspace_entries(&root, generous_walk_deadline(), 5);
26925 assert!(truncated);
26926 assert!(budget.entry_cap_exceeded);
26927 assert!(!budget.time_budget_exceeded);
26928 assert!(entries.len() <= 5);
26929
26930 let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
26931 .expect("an entry-cap-truncated walk must report a truncation marker");
26932 assert!(inspection_truncation.walk_entry_cap_exceeded);
26933 assert!(!inspection_truncation.walk_time_budget_exceeded);
26934 assert!(!inspection_truncation.git_time_budget_exceeded);
26935 assert!(inspection_truncation.entries_visited >= 5);
26936
26937 std::fs::remove_dir_all(&root).unwrap();
26938 }
26939
26940 #[test]
26941 fn workspace_walk_within_budget_reports_no_truncation() {
26942 let root = temporary_workspace_root("tree-no-truncation");
26943 std::fs::create_dir_all(&root).unwrap();
26944 std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
26945
26946 let (_entries, truncated, budget) =
26947 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26948 assert!(!truncated);
26949 assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
26950
26951 std::fs::remove_dir_all(&root).unwrap();
26952 }
26953
26954 #[test]
26955 fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
26956 let root = temporary_workspace_root("tree-prefix-siblings");
26957 std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
26958 std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
26959 std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
26960 std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
26961
26962 let (entries, truncated, _budget) =
26963 collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
26964 assert!(!truncated);
26965 let paths = entries
26966 .iter()
26967 .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
26968 .collect::<Vec<_>>();
26969 assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
26970 assert_eq!(
26971 paths,
26972 vec![
26973 "gate4agent-c2".to_owned(),
26974 "gate4agent-c2-client".to_owned(),
26975 "gate4agent-c2-client/lib.rs".to_owned(),
26976 "gate4agent-c2/src".to_owned(),
26977 "gate4agent-c2/src/lib.rs".to_owned(),
26978 ],
26979 );
26980
26981 std::fs::remove_dir_all(&root).unwrap();
26982 }
26983
26984 #[test]
26985 fn git_parsers_cap_status_and_commit_results() {
26986 let mut status_output = Vec::new();
26987 for index in 0..=GIT_STATUS_MAX_ENTRIES {
26988 status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
26989 }
26990 let mut snapshot = git_snapshot_for_parser();
26991 parse_git_status(&status_output, &mut snapshot);
26992 assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
26993 assert!(snapshot.truncated);
26994 assert_eq!(snapshot.status[0].index_status, " ");
26995 assert_eq!(snapshot.status[0].worktree_status, "M");
26996
26997 snapshot.truncated = false;
26998 let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
26999 .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27000 .collect::<String>();
27001 parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27002 assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27003 assert!(snapshot.truncated);
27004 }
27005
27006 #[test]
27007 fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27008 let mut snapshot = git_snapshot_for_parser();
27009 parse_git_status(
27010 b"R src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27011 &mut snapshot,
27012 );
27013
27014 assert!(!snapshot.truncated);
27015 assert_eq!(snapshot.status.len(), 3);
27016 assert_eq!(snapshot.status[0].index_status, "R");
27017 assert_eq!(snapshot.status[0].worktree_status, " ");
27018 assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27019 assert_eq!(
27020 snapshot.status[0]
27021 .previous_path
27022 .as_ref()
27023 .and_then(RepositoryPath::as_utf8),
27024 Some("src/previous name.rs"),
27025 );
27026 assert_eq!(snapshot.status[1].index_status, " ");
27027 assert_eq!(snapshot.status[1].worktree_status, "C");
27028 assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27029 assert_eq!(
27030 snapshot.status[1]
27031 .previous_path
27032 .as_ref()
27033 .and_then(RepositoryPath::as_utf8),
27034 Some("src/original.rs"),
27035 );
27036 assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27037 assert!(snapshot.status[2].previous_path.is_none());
27038 }
27039
27040 #[test]
27041 fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27042 let mut snapshot = git_snapshot_for_parser();
27043 parse_git_status(
27044 b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R current.rs\0",
27045 &mut snapshot,
27046 );
27047
27048 assert!(snapshot.truncated);
27049 assert_eq!(snapshot.status.len(), 1);
27050 assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27051 assert!(snapshot.status[0].previous_path.is_none());
27052 }
27053
27054 #[tokio::test]
27055 async fn observer_can_browse_host_directories_without_controller() {
27056 let root = temporary_workspace_root("host-directory-observer");
27057 std::fs::create_dir_all(root.join("directory-b")).unwrap();
27058 std::fs::create_dir_all(root.join("directory-a")).unwrap();
27059 std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27060 let workspace_id = WorkspaceId::new("primary").unwrap();
27061 let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27062 let catalog = active_registry().unwrap();
27063 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27064 let shared = NodeShared::new(
27065 handle,
27066 "fixture-token".to_owned(),
27067 NodeId::new("node-1").unwrap(),
27068 vec![workspace],
27069 vec![agent("claude")],
27070 );
27071 let request = NodeRequest::BrowseHostDirectories {
27072 directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27073 after: None,
27074 };
27075 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27076 capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27077 }));
27078 assert!(!request_uses_unnegotiated_capability(
27079 &request,
27080 &baseline_capabilities().unwrap(),
27081 ));
27082 assert!(request_uses_unnegotiated_capability(&request, &[]));
27083
27084 let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27085 .await
27086 .unwrap();
27087 let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27088 panic!("host directory browse returned another response");
27089 };
27090 assert_eq!(
27091 listing
27092 .entries
27093 .iter()
27094 .map(|entry| entry.display_name.as_str())
27095 .collect::<Vec<_>>(),
27096 vec!["directory-a", "directory-b"],
27097 );
27098 assert!(listing.entries.iter().all(|entry| !entry.is_link));
27099 assert!(!listing.incomplete);
27100 std::fs::remove_dir_all(root).unwrap();
27101 }
27102
27103 #[tokio::test]
27104 async fn observer_can_inspect_only_a_registered_workspace() {
27105 let root = temporary_workspace_root("observer");
27106 std::fs::create_dir_all(root.join("src")).unwrap();
27107 std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27108 let workspace_id = WorkspaceId::new("primary").unwrap();
27109 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27110 let catalog = active_registry().unwrap();
27111 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27112 let shared = NodeShared::new(
27113 handle,
27114 "fixture-token".to_owned(),
27115 NodeId::new("node-1").unwrap(),
27116 vec![workspace],
27117 vec![agent("claude")],
27118 );
27119
27120 let response = process_request_inner(
27121 &shared,
27122 77,
27123 ClientRole::Observer,
27124 NodeRequest::InspectWorkspace {
27125 workspace_id: workspace_id.clone(),
27126 },
27127 )
27128 .await
27129 .unwrap();
27130 let NodeResponse::WorkspaceInspected { inspection } = response else {
27131 panic!("workspace inspection returned another response");
27132 };
27133 assert_eq!(inspection.workspace_id, workspace_id);
27134 assert!(inspection.entries.iter().any(|entry| {
27135 entry.relative_path.as_utf8() == Some("src/main.rs")
27136 }));
27137
27138 let unknown = process_request_inner(
27139 &shared,
27140 77,
27141 ClientRole::Observer,
27142 NodeRequest::InspectWorkspace {
27143 workspace_id: WorkspaceId::new("unknown").unwrap(),
27144 },
27145 )
27146 .await
27147 .unwrap_err();
27148 assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27149 std::fs::remove_dir_all(&root).unwrap();
27150 }
27151
27152 #[test]
27153 fn context_pack_source_accepts_only_running_or_successful_exit() {
27154 assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27155 assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27156 exit_code: Some(0),
27157 }));
27158 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27159 exit_code: Some(1),
27160 }));
27161 assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27162 exit_code: None,
27163 }));
27164 assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27165 message: "provider failed".to_owned(),
27166 }));
27167 assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27168 assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27169 assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27170 }
27171
27172 #[test]
27173 fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27174 let git = git_snapshot_for_parser();
27175 let first = ContextPackRepositoryObservation {
27176 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27177 git: git.clone(),
27178 files: Vec::new(),
27179 };
27180 let second = ContextPackRepositoryObservation {
27181 head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27182 git,
27183 files: Vec::new(),
27184 };
27185
27186 let error = stable_context_pack_repository(first, second).unwrap_err();
27187
27188 assert_eq!(
27189 error.code,
27190 NodeFailureCode::ContextPackMaterializationFailed,
27191 );
27192 }
27193
27194 #[test]
27195 fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27196 let git = git_snapshot_for_parser();
27197 let first = ContextPackRepositoryObservation {
27198 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27199 git: git.clone(),
27200 files: vec![ContextPackRepositoryFileSource::utf8(
27201 "README.md",
27202 "first\n".to_owned(),
27203 6,
27204 )],
27205 };
27206 let second = ContextPackRepositoryObservation {
27207 head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27208 git,
27209 files: vec![ContextPackRepositoryFileSource::utf8(
27210 "README.md",
27211 "other\n".to_owned(),
27212 6,
27213 )],
27214 };
27215
27216 let error = stable_context_pack_repository(first, second).unwrap_err();
27217
27218 assert_eq!(
27219 error.code,
27220 NodeFailureCode::ContextPackMaterializationFailed,
27221 );
27222 }
27223
27224 #[tokio::test]
27225 async fn native_session_catalog_zero_active_session_fixture() {
27226 let workspace_root = temporary_workspace_root("native-catalog-workspace");
27227 let history_root = temporary_workspace_root("native-catalog-history");
27228 let outside_root = temporary_workspace_root("native-catalog-outside");
27229 std::fs::create_dir_all(&workspace_root).unwrap();
27230 std::fs::create_dir_all(&history_root).unwrap();
27231 std::fs::create_dir_all(&outside_root).unwrap();
27232 let transcript = |session_id: &str, cwd: &Path, title: &str| {
27233 [
27234 serde_json::json!({
27235 "type": "user",
27236 "sessionId": session_id,
27237 "cwd": cwd.to_str().unwrap(),
27238 "message": { "content": "raw user text must not cross the catalog wire" }
27239 }),
27240 serde_json::json!({
27241 "type": "assistant",
27242 "sessionId": session_id,
27243 "cwd": cwd.to_str().unwrap(),
27244 "message": { "model": "claude-sonnet", "content": "raw answer" }
27245 }),
27246 serde_json::json!({
27247 "type": "ai-title",
27248 "sessionId": session_id,
27249 "aiTitle": title
27250 }),
27251 ]
27252 .into_iter()
27253 .map(|record| record.to_string())
27254 .collect::<Vec<_>>()
27255 .join("\n")
27256 };
27257 std::fs::write(
27258 history_root.join("stable-session.jsonl"),
27259 transcript("stable-session", &workspace_root, "Workspace review"),
27260 )
27261 .unwrap();
27262 std::fs::write(
27263 history_root.join("outside-session.jsonl"),
27264 transcript("outside-session", &outside_root, "Outside review"),
27265 )
27266 .unwrap();
27267 let history = NativeHistoryConfig::new(vec![
27268 NativeHistoryRoot::new(
27269 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27270 HistorySourceLayout::SingleNdjson,
27271 history_root.clone(),
27272 )
27273 .unwrap(),
27274 ])
27275 .unwrap();
27276 let catalog = active_registry().unwrap();
27277 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27278 assert!(handle.snapshot().sessions.is_empty());
27279 let workspace_id = WorkspaceId::new("primary").unwrap();
27280 let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27281 let mut shared = NodeShared::new(
27282 handle,
27283 "fixture-token".to_owned(),
27284 NodeId::new("node-native-catalog").unwrap(),
27285 vec![workspace],
27286 vec![agent("claude")],
27287 );
27288 shared.native_session_catalog = Some(Arc::new(Mutex::new(
27289 NativeSessionCatalogAuthority::new(history),
27290 )));
27291 let route = NativeSessionCatalogRoute::workspace(
27292 workspace_id.clone(),
27293 agent("claude"),
27294 );
27295 let request = NodeRequest::CatalogNativeSessions {
27296 route: route.clone(),
27297 limit: 2,
27298 };
27299 let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27300 .await
27301 .unwrap_err();
27302 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27303 let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27304 .await
27305 .unwrap();
27306 let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27307 panic!("native catalog returned a different response");
27308 };
27309 let summary = summary.expect("node must emit native session catalog summary metadata");
27310 assert_eq!(entries.len(), 1);
27311 assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27312 assert_eq!(entries[0].external_group, None);
27313 assert_eq!(entries[0].record_id, None);
27314 assert_eq!(entries[0].title, None);
27315 assert_eq!(entries[0].model, None);
27316 assert_eq!(entries[0].message_count, 0);
27317 let encoded_entries = serde_json::to_string(&entries).unwrap();
27318 assert!(!encoded_entries.contains("stable-session"));
27319 assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27320 let page_request = NodeRequest::PageNativeSessions {
27321 route: route.clone(),
27322 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27323 catalog_revision: summary.catalog_revision,
27324 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27325 after_selection_id: None,
27326 limit: 2,
27327 };
27328 let observer = process_request_inner(
27329 &shared,
27330 7,
27331 ClientRole::Observer,
27332 page_request.clone(),
27333 )
27334 .await
27335 .unwrap_err();
27336 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27337 let page_response = process_request_inner(
27338 &shared,
27339 7,
27340 ClientRole::Operator,
27341 page_request.clone(),
27342 )
27343 .await
27344 .unwrap();
27345 assert!(matches!(
27346 page_response,
27347 NodeResponse::NativeSessionsPaged { page, .. }
27348 if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27349 ));
27350 let stale = process_request_inner(
27351 &shared,
27352 7,
27353 ClientRole::Operator,
27354 NodeRequest::PageNativeSessions {
27355 route: route.clone(),
27356 window: crate::protocol::NativeSessionCatalogWindow::Recent,
27357 catalog_revision: summary.catalog_revision.wrapping_add(1),
27358 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27359 after_selection_id: None,
27360 limit: 2,
27361 },
27362 )
27363 .await
27364 .unwrap_err();
27365 assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27366 let selection = NativeSessionSelection {
27367 route: route.clone(),
27368 catalog_revision: summary.catalog_revision,
27369 recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27370 selection_id: entries[0].selection_id.clone(),
27371 };
27372 let preview_response = process_request_inner(
27373 &shared,
27374 7,
27375 ClientRole::Operator,
27376 NodeRequest::PreviewNativeSession {
27377 selection: selection.clone(),
27378 message_limit: 1,
27379 },
27380 )
27381 .await
27382 .unwrap();
27383 let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27384 panic!("native preview returned a different response");
27385 };
27386 assert_eq!(preview.messages.len(), 1);
27387 assert!(preview.message_count_exact);
27388 assert_eq!(preview.messages[0].text, "raw answer");
27389 assert!(preview.truncated);
27390
27391 let index_request = NodeRequest::IndexNativeSession {
27392 selection: selection.clone(),
27393 display_name: "Saved Claude".to_owned(),
27394 };
27395 let observer = process_request_inner(
27396 &shared,
27397 7,
27398 ClientRole::Observer,
27399 index_request.clone(),
27400 )
27401 .await
27402 .unwrap_err();
27403 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27404 let without_controller = process_request_inner(
27405 &shared,
27406 7,
27407 ClientRole::Operator,
27408 index_request.clone(),
27409 )
27410 .await
27411 .unwrap_err();
27412 assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27413 let acquired = process_request_inner(
27414 &shared,
27415 7,
27416 ClientRole::Operator,
27417 NodeRequest::AcquireController { lease_ms: 5_000 },
27418 )
27419 .await
27420 .unwrap();
27421 assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27422 let indexed = process_request_inner(
27423 &shared,
27424 7,
27425 ClientRole::Operator,
27426 index_request,
27427 )
27428 .await
27429 .unwrap();
27430 assert!(indexed.native_session_index_contract_is_valid());
27431 let NodeResponse::NativeSessionIndexed {
27432 selection: echoed_selection,
27433 record,
27434 } = indexed else {
27435 panic!("native session index returned a different response");
27436 };
27437 assert_eq!(echoed_selection, selection);
27438 let recataloged = process_request_inner(
27439 &shared,
27440 7,
27441 ClientRole::Operator,
27442 NodeRequest::CatalogNativeSessions {
27443 route,
27444 limit: 2,
27445 },
27446 )
27447 .await
27448 .unwrap();
27449 let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27450 panic!("native catalog returned a different response after indexing");
27451 };
27452 assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27453
27454 let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27455 let external_catalog = process_request_inner(
27456 &shared,
27457 7,
27458 ClientRole::Operator,
27459 NodeRequest::CatalogNativeSessions {
27460 route: unregistered_route.clone(),
27461 limit: 2,
27462 },
27463 )
27464 .await
27465 .unwrap();
27466 let external_json = serde_json::to_string(&external_catalog).unwrap();
27467 assert!(!external_json.contains("outside-session"));
27468 assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27469 assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27470 assert!(!external_json.contains("\"session_id\""));
27471 assert!(!external_json.contains("\"cwd\""));
27472 assert!(!external_json.contains("\"path\""));
27473 let NodeResponse::NativeSessionsCataloged {
27474 route: echoed_route,
27475 entries: external_entries,
27476 summary: Some(external_summary),
27477 } = external_catalog
27478 else {
27479 panic!("unregistered native catalog returned a different response");
27480 };
27481 assert_eq!(echoed_route, unregistered_route);
27482 assert_eq!(external_entries.len(), 1);
27483 assert_eq!(external_entries[0].record_id, None);
27484 let external_group = external_entries[0]
27485 .external_group
27486 .as_ref()
27487 .expect("unregistered session must have an external group");
27488 assert_eq!(
27489 external_group.kind,
27490 gate4agent_types::NativeSessionExternalGroupKind::Project,
27491 );
27492 assert!(external_group.group_id.starts_with("external-"));
27493 assert_eq!(
27494 external_group.display_name,
27495 outside_root.file_name().unwrap().to_string_lossy(),
27496 );
27497 let external_selection = NativeSessionSelection {
27498 route: unregistered_route,
27499 catalog_revision: external_summary.catalog_revision,
27500 recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27501 selection_id: external_entries[0].selection_id.clone(),
27502 };
27503 let external_preview = process_request_inner(
27504 &shared,
27505 7,
27506 ClientRole::Operator,
27507 NodeRequest::PreviewNativeSession {
27508 selection: external_selection.clone(),
27509 message_limit: 1,
27510 },
27511 )
27512 .await
27513 .unwrap();
27514 let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27515 assert!(!external_preview_json.contains("outside-session"));
27516 assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27517 assert!(!external_preview_json.contains("\"session_id\""));
27518 let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27519 panic!("unregistered native preview returned a different response");
27520 };
27521 assert_eq!(preview.messages.len(), 1);
27522 let external_index = process_request_inner(
27523 &shared,
27524 7,
27525 ClientRole::Operator,
27526 NodeRequest::IndexNativeSession {
27527 selection: external_selection,
27528 display_name: "External Claude".to_owned(),
27529 },
27530 )
27531 .await
27532 .unwrap_err();
27533 assert_eq!(
27534 external_index.code,
27535 NodeFailureCode::WorkspaceRegistrationRequired,
27536 );
27537 let record_response = process_request_inner(
27538 &shared,
27539 7,
27540 ClientRole::Operator,
27541 NodeRequest::PreviewSessionRecord {
27542 record_id: record.record_id.clone(),
27543 message_limit: 2,
27544 },
27545 )
27546 .await
27547 .unwrap();
27548 let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27549 panic!("record preview returned a different response");
27550 };
27551 assert_eq!(record_id, record.record_id);
27552 assert_eq!(preview.messages.len(), 2);
27553 let encoded = serde_json::to_string(&preview).unwrap();
27554 assert!(!encoded.contains("stable-session"));
27555 assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27556 let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27557 panic!("record preview history summary returned a different response");
27558 };
27559 let summaries = events
27560 .iter()
27561 .filter_map(|envelope| match &envelope.event {
27562 NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27563 if record_id == &record.record_id =>
27564 {
27565 Some(*summary)
27566 }
27567 _ => None,
27568 })
27569 .collect::<Vec<_>>();
27570 assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27571 let summary_json = serde_json::to_string(&summaries).unwrap();
27572 for private in [
27573 "stable-session",
27574 "raw user text must not cross the catalog wire",
27575 "raw answer",
27576 "Workspace review",
27577 "claude-sonnet",
27578 ] {
27579 assert!(!summary_json.contains(private), "{private}");
27580 }
27581 let identity = record.provider_session.clone().unwrap();
27582 assert_eq!(
27583 shared.revalidate_session_record_preview(&record, &identity),
27584 Ok(())
27585 );
27586 shared.remove_record_memory(&record.record_id);
27587 assert_eq!(
27588 shared
27589 .revalidate_session_record_preview(&record, &identity)
27590 .unwrap_err()
27591 .code,
27592 NodeFailureCode::UnknownSessionRecord
27593 );
27594 let released = process_request_inner(
27595 &shared,
27596 7,
27597 ClientRole::Operator,
27598 NodeRequest::ReleaseController,
27599 )
27600 .await
27601 .unwrap();
27602 assert!(matches!(released, NodeResponse::Controller { controller: None }));
27603 assert!(shared.controller_state().is_none());
27604 std::fs::remove_dir_all(workspace_root).unwrap();
27605 std::fs::remove_dir_all(history_root).unwrap();
27606 std::fs::remove_dir_all(outside_root).unwrap();
27607 }
27608
27609 #[tokio::test]
27610 async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
27611 let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
27612 std::fs::create_dir_all(&history_root).unwrap();
27613 let history = NativeHistoryConfig::new(vec![
27614 NativeHistoryRoot::new(
27615 gate4agent_types::AdapterId::new("claude-code").unwrap(),
27616 HistorySourceLayout::SingleNdjson,
27617 history_root.clone(),
27618 )
27619 .unwrap(),
27620 ])
27621 .unwrap();
27622 let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
27623 let held = catalog.lock().unwrap();
27624 let result = timeout(
27625 Duration::from_secs(1),
27626 run_native_session_catalog_operation(
27627 Arc::clone(&catalog),
27628 "native session catalog",
27629 |_| Ok::<(), std::convert::Infallible>(()),
27630 ),
27631 )
27632 .await
27633 .expect("concurrent native catalog request must not wait for the operation deadline")
27634 .unwrap_err();
27635 assert_eq!(result.code, NodeFailureCode::BackendBusy);
27636 drop(held);
27637 std::fs::remove_dir_all(history_root).unwrap();
27638 }
27639
27640 fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
27641 let unique = std::time::SystemTime::now()
27642 .duration_since(std::time::UNIX_EPOCH)
27643 .unwrap()
27644 .as_nanos();
27645 std::env::temp_dir().join(format!(
27646 "gate4agent-node-{label}-{}-{unique}",
27647 std::process::id(),
27648 ))
27649 }
27650
27651 fn git_snapshot_for_parser() -> GitSnapshot {
27652 GitSnapshot {
27653 is_repository: true,
27654 branch: Some("main".to_owned()),
27655 status: Vec::new(),
27656 recent_commits: Vec::new(),
27657 worktrees: Vec::new(),
27658 managed_worktree: None,
27659 truncated: false,
27660 diagnostic: None,
27661 }
27662 }
27663
27664 #[tokio::test]
27665 async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
27666 let root = temporary_workspace_root("managed-git-scope");
27667 std::fs::create_dir_all(&root).unwrap();
27668 run_test_git(&root, &["init"]);
27669 run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
27670 run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
27671 std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
27672 run_test_git(&root, &["add", "seed.txt"]);
27673 run_test_git(&root, &["commit", "-m", "seed"]);
27674 run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
27675
27676 let workspace_id = WorkspaceId::new("managed-a").unwrap();
27677 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27678 let catalog = active_registry().unwrap();
27679 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27680 let shared = NodeShared::new(
27681 handle,
27682 "fixture-token".to_owned(),
27683 NodeId::new("node-1").unwrap(),
27684 vec![workspace],
27685 vec![agent("claude")],
27686 );
27687 let mut lease = ManagedWorktreeLeaseRecord {
27688 lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
27689 source_workspace_id: WorkspaceId::new("primary").unwrap(),
27690 workspace_id: workspace_id.clone(),
27691 profile_id: WorktreeProfileId::new("default").unwrap(),
27692 profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
27693 target_root: root.to_string_lossy().into_owned(),
27694 branch: "gate4agent/a".to_owned(),
27695 base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
27696 expected_head: None,
27697 retention: ManagedWorktreeRetention::Retain,
27698 state: ManagedWorktreeLeaseState::InUse,
27699 session_holders: vec![ManagedWorktreeSessionHolder {
27700 incarnation_id: shared.incarnation_id,
27701 instance_id: AgentInstanceId(7),
27702 generation: SessionGeneration(1),
27703 }],
27704 record_holders: Vec::new(),
27705 cleanup_failure: None,
27706 created_at_unix_ms: 1,
27707 updated_at_unix_ms: 1,
27708 };
27709 *shared
27710 .managed_worktrees
27711 .lock()
27712 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27713 ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
27714
27715 let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
27716 let scope = inspection.git.managed_worktree.unwrap();
27717 assert_eq!(scope.lease_id, lease.lease_id);
27718 assert_eq!(scope.branch, "gate4agent/a");
27719 assert_eq!(scope.active_session_count, 1);
27720
27721 lease.branch = "gate4agent/b".to_owned();
27722 *shared
27723 .managed_worktrees
27724 .lock()
27725 .unwrap_or_else(|poisoned| poisoned.into_inner()) =
27726 ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
27727 let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
27728 assert!(mismatch.git.managed_worktree.is_none());
27729 std::fs::remove_dir_all(root).unwrap();
27730 }
27731
27732 #[tokio::test]
27733 async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
27734 let root = temporary_workspace_root("workspace-file-write-controller");
27735 std::fs::create_dir_all(root.join("src")).unwrap();
27736 std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
27737 let workspace_id = WorkspaceId::new("primary").unwrap();
27738 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27739 let catalog = active_registry().unwrap();
27740 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27741 let shared = NodeShared::new(
27742 handle,
27743 "fixture-token".to_owned(),
27744 NodeId::new("node-1").unwrap(),
27745 vec![workspace],
27746 vec![agent("claude")],
27747 );
27748 let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
27749 let expected_revision = workspace_file_revision(b"old\n");
27750 let request = NodeRequest::WriteWorkspaceFile {
27751 workspace_id: workspace_id.clone(),
27752 path: path.clone(),
27753 expected_revision: expected_revision.clone(),
27754 text: "new\n".to_owned(),
27755 };
27756 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27757 .await
27758 .unwrap_err();
27759 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27760 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27761 .await
27762 .unwrap_err();
27763 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27764 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27765 let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
27766 let NodeResponse::WorkspaceFileWritten { file } = response else {
27767 panic!("workspace file write returned another response");
27768 };
27769 assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
27770 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27771 let stale = process_request_inner(
27772 &shared,
27773 77,
27774 ClientRole::Operator,
27775 NodeRequest::WriteWorkspaceFile {
27776 workspace_id,
27777 path,
27778 expected_revision,
27779 text: "lost update\n".to_owned(),
27780 },
27781 )
27782 .await
27783 .unwrap_err();
27784 assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
27785 assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
27786 std::fs::remove_dir_all(root).unwrap();
27787 }
27788
27789 #[tokio::test]
27790 async fn workspace_entry_create_requires_controller_and_never_overwrites() {
27791 let root = temporary_workspace_root("workspace-entry-create-controller");
27792 std::fs::create_dir_all(root.join("src")).unwrap();
27793 let workspace_id = WorkspaceId::new("primary").unwrap();
27794 let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27795 let catalog = active_registry().unwrap();
27796 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27797 let shared = NodeShared::new(
27798 handle,
27799 "fixture-token".to_owned(),
27800 NodeId::new("node-1").unwrap(),
27801 vec![workspace],
27802 vec![agent("claude")],
27803 );
27804 let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
27805 let request = NodeRequest::CreateWorkspaceFile {
27806 workspace_id: workspace_id.clone(),
27807 path: path.clone(),
27808 };
27809 assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27810 capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
27811 }));
27812 assert!(!request_uses_unnegotiated_capability(
27813 &request,
27814 &baseline_capabilities().unwrap(),
27815 ));
27816 assert!(request_uses_unnegotiated_capability(&request, &[]));
27817 let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
27818 .await
27819 .unwrap_err();
27820 assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27821 let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27822 .await
27823 .unwrap_err();
27824 assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
27825 shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
27826
27827 let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
27828 .await
27829 .unwrap();
27830 let NodeResponse::WorkspaceFileCreated { file } = response else {
27831 panic!("workspace file create returned another response");
27832 };
27833 assert_eq!(file.workspace_id, workspace_id);
27834 assert_eq!(file.path, path);
27835 assert_eq!(file.revision, Some(workspace_file_revision(&[])));
27836 assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
27837
27838 let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
27839 .await
27840 .unwrap_err();
27841 assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
27842
27843 let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
27844 let response = process_request_inner(
27845 &shared,
27846 77,
27847 ClientRole::Operator,
27848 NodeRequest::CreateWorkspaceDirectory {
27849 workspace_id: workspace_id.clone(),
27850 path: directory_path.clone(),
27851 },
27852 )
27853 .await
27854 .unwrap();
27855 assert_eq!(
27856 response,
27857 NodeResponse::WorkspaceDirectoryCreated {
27858 workspace_id: workspace_id.clone(),
27859 entry: WorkspaceEntry {
27860 relative_path: directory_path,
27861 kind: WorkspaceEntryKind::Directory,
27862 },
27863 },
27864 );
27865 assert!(root.join("src/nested").is_dir());
27866
27867 let missing_parent = process_request_inner(
27868 &shared,
27869 77,
27870 ClientRole::Operator,
27871 NodeRequest::CreateWorkspaceFile {
27872 workspace_id: workspace_id.clone(),
27873 path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
27874 },
27875 )
27876 .await
27877 .unwrap_err();
27878 assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
27879 let non_directory_parent = process_request_inner(
27880 &shared,
27881 77,
27882 ClientRole::Operator,
27883 NodeRequest::CreateWorkspaceFile {
27884 workspace_id,
27885 path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
27886 },
27887 )
27888 .await
27889 .unwrap_err();
27890 assert_eq!(
27891 non_directory_parent.code,
27892 NodeFailureCode::RepositoryParentNotDirectory,
27893 );
27894 std::fs::remove_dir_all(root).unwrap();
27895 }
27896
27897 #[tokio::test]
27898 async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
27899 let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
27900 std::fs::create_dir_all(root.join("src")).unwrap();
27901 #[cfg(windows)]
27902 let root = std::fs::canonicalize(root).unwrap();
27903 let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
27904 let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
27905 let barrier = Arc::new(std::sync::Barrier::new(2));
27906 let done = Arc::new(AtomicBool::new(false));
27907 let task_commit_state = Arc::clone(&commit_state);
27908 let task_barrier = Arc::clone(&barrier);
27909 let task_done = Arc::clone(&done);
27910 let task_root = root.clone();
27911 let task = tokio::task::spawn_blocking(move || {
27912 task_barrier.wait();
27913 let result = create_workspace_file_on_disk(
27914 &task_root,
27915 &path,
27916 &task_commit_state,
27917 );
27918 task_done.store(true, Ordering::Release);
27919 result
27920 });
27921
27922 let error = settle_workspace_entry_create(
27923 task,
27924 Arc::clone(&commit_state),
27925 Duration::from_millis(10),
27926 "fixture deadline",
27927 "fixture task failed",
27928 )
27929 .await
27930 .unwrap_err();
27931 assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
27932 assert_eq!(
27933 commit_state.load(Ordering::Acquire),
27934 WORKSPACE_ENTRY_CREATE_CANCELED,
27935 );
27936
27937 barrier.wait();
27938 timeout(Duration::from_secs(1), async {
27939 while !done.load(Ordering::Acquire) {
27940 tokio::task::yield_now().await;
27941 }
27942 })
27943 .await
27944 .expect("canceled blocking create did not settle");
27945 assert!(!root.join("src/late.rs").exists());
27946 std::fs::remove_dir_all(root).unwrap();
27947 }
27948}
27949
27950#[cfg(test)]
27951mod provisional_spawn_tests {
27952 use super::*;
27953 use std::sync::Arc;
27954
27955 fn shared() -> NodeShared {
27956 let catalog = active_registry().unwrap();
27957 let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27958 let workspace = WorkspaceConfig::new(
27959 WorkspaceId::new("primary").unwrap(),
27960 std::env::current_dir().unwrap(),
27961 )
27962 .unwrap()
27963 .with_worktree_service_mode(WorktreeServiceMode::Manual);
27964 NodeShared::new(
27965 handle,
27966 "fixture-token".to_owned(),
27967 NodeId::new("node-provisional-test").unwrap(),
27968 vec![workspace],
27969 vec![AgentId::new("claude").unwrap()],
27970 )
27971 }
27972
27973 #[test]
27976 fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
27977 let shared = shared();
27978 let ws = WorkspaceId::new("primary").unwrap();
27979 let mut ids = Vec::with_capacity(50);
27980 for i in 1u64..=50 {
27981 let id = AgentInstanceId(i);
27982 ids.push(id);
27983 let address = SessionAddress {
27984 workspace_id: ws.clone(),
27985 session: SessionKey {
27986 instance_id: id,
27987 generation: SessionGeneration(1),
27988 },
27989 };
27990 shared.mark_spawn_provisional(address);
27991 }
27992 assert_eq!(
27993 shared
27994 .provisional_spawns
27995 .lock()
27996 .unwrap_or_else(|p| p.into_inner())
27997 .len(),
27998 50
27999 );
28000 for id in ids.iter().step_by(2) {
28001 shared.sessionize_spawn(*id);
28002 }
28003 assert_eq!(
28004 shared
28005 .provisional_spawns
28006 .lock()
28007 .unwrap_or_else(|p| p.into_inner())
28008 .len(),
28009 25
28010 );
28011 assert!(shared.take_due_provisional_spawns().is_empty());
28012 {
28013 let mut map = shared
28014 .provisional_spawns
28015 .lock()
28016 .unwrap_or_else(|p| p.into_inner());
28017 let past = Instant::now() - Duration::from_secs(30);
28018 for (_, (_, at)) in map.iter_mut() {
28019 *at = past;
28020 }
28021 }
28022 let due = shared.take_due_provisional_spawns();
28023 assert_eq!(due.len(), 25);
28024 let due_ids: std::collections::BTreeSet<_> =
28025 due.iter().map(|a| a.session.instance_id.0).collect();
28026 let expected: std::collections::BTreeSet<_> =
28027 ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28028 assert_eq!(due_ids, expected);
28029 assert!(shared
28030 .provisional_spawns
28031 .lock()
28032 .unwrap_or_else(|p| p.into_inner())
28033 .is_empty());
28034 }
28035
28036 #[test]
28039 fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28040 let shared = Arc::new(shared());
28041 let ws = WorkspaceId::new("primary").unwrap();
28042 let mut handles = Vec::new();
28043 for i in 1u64..=20 {
28044 let shared = Arc::clone(&shared);
28045 let ws = ws.clone();
28046 handles.push(std::thread::spawn(move || {
28047 let id = AgentInstanceId(i);
28048 let address = SessionAddress {
28049 workspace_id: ws,
28050 session: SessionKey {
28051 instance_id: id,
28052 generation: SessionGeneration(1),
28053 },
28054 };
28055 shared.mark_spawn_provisional(address);
28056 id
28057 }));
28058 }
28059 let mut ids = Vec::new();
28060 for h in handles {
28061 ids.push(h.join().unwrap());
28062 }
28063 ids.sort_by_key(|id| id.0);
28064 assert_eq!(ids.len(), 20);
28065 ids.dedup();
28066 assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28067 assert_eq!(
28068 shared
28069 .provisional_spawns
28070 .lock()
28071 .unwrap_or_else(|p| p.into_inner())
28072 .len(),
28073 20
28074 );
28075 shared.sessionize_spawn(AgentInstanceId(7));
28076 let left: Vec<_> = shared
28077 .provisional_spawns
28078 .lock()
28079 .unwrap_or_else(|p| p.into_inner())
28080 .keys()
28081 .map(|id| id.0)
28082 .collect();
28083 assert!(!left.contains(&7));
28084 assert_eq!(left.len(), 19);
28085 }
28086}
28087
28088
28089#[cfg(unix)]
28090extern "C" {
28091 #[link_name = "geteuid"]
28092 fn libc_geteuid() -> u32;
28093}
28094
28095#[cfg(test)]
28096mod call_home_link_tests {
28097 use super::*;
28098
28099 #[test]
28100 fn call_home_backoff_is_capped_and_monotonic() {
28101 let mut last = Duration::ZERO;
28102 for attempt in 1..20 {
28103 let next = call_home_backoff(attempt);
28104 assert!(next >= last);
28105 assert!(next <= Duration::from_secs(8));
28106 last = next;
28107 }
28108 assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28109 }
28110
28111 #[tokio::test]
28112 async fn call_home_socket_gets_keepalive() {
28113 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28114 let addr = listener.local_addr().unwrap();
28115 let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28116 let client = client.unwrap();
28117 enable_call_home_keepalive(&client).unwrap();
28118 let sock = socket2::SockRef::from(&client);
28119 assert!(sock.keepalive().unwrap());
28120 #[cfg(any(target_os = "linux", target_os = "macos"))]
28121 assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28122 }
28123}